diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 2dbf920a69..e42398340b 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -2662,6 +2662,9 @@ "simplification/2026-09-19-deepseek-messages-only.i18n.yaml": "sha256:7ffd708c5823c39cc9536178292e7e7fa57db5267bbc9acdda0a87e4953d5ba8", "simplification/2026-09-19-deepseek-messages-only.md": "sha256:241861200ce73e02f77d182633d6ea10b1f29c696c81bff002680762db01d9b5", "simplification/2026-09-19-deepseek-messages-only.zh.md": "sha256:311ef6f8f4cf60dae2a43ca94d772028b645631501fb3648aaeb406ba174991e", + "simplification/2026-09-26-team-direct-inbox.i18n.yaml": "sha256:9a6f1137f0c68d976fda4f44c5c301b851a8ee8e46965f3ea808ec5551045fb0", + "simplification/2026-09-26-team-direct-inbox.md": "sha256:88f7d9be3ae0b740ddb5a61a16e3c64044a207584b973acab8c3a40c36a7ff30", + "simplification/2026-09-26-team-direct-inbox.zh.md": "sha256:d6d1fafa4aa7035a11930cca3bf998d676a62b5f9b75176dff69c3c5ff6b92f0", "testing/2026-06-11-property-based-testing.i18n.yaml": "sha256:9d378912101ad30203f1fcbcad23e0f5d6ec6f39e04a83f17f90887b5c5d761e", "testing/2026-06-11-property-based-testing.md": "sha256:cca3b6299784cff94965f21b6d59966109a0bb41fdc2f8cf56653fa6c417a20a", "testing/2026-06-11-property-based-testing.zh.md": "sha256:09f3d1e2defd2792f87fb0e9892fb7e1db006f3c75ea4581f97e9cde4ea98175", diff --git a/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml new file mode 100644 index 0000000000..50788468df --- /dev/null +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md +2026-09-26-team-direct-inbox.md: 9b90d7ae30e0b711c602de9280cc11845d0525f0 +2026-09-26-team-direct-inbox.zh.md: 8838fb4a2824838b76142f30a6ff4d0aedf0276b diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md rename to .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md index 58d18d0ecf..9b90d7ae30 100644 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md @@ -1,6 +1,7 @@ # Agent Note: Team messages use the target inbox Status: implemented +Archived: 2026-10-15 English | [中文](2026-09-26-team-direct-inbox.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md rename to .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md index 6928631c8f..8838fb4a28 100644 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md @@ -1,6 +1,7 @@ # Agent Note: Team 消息使用目标 inbox Status: implemented +Archived: 2026-10-15 [English](2026-09-26-team-direct-inbox.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index d4ad622140..0dc3371001 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -6,23 +6,23 @@ en: ca120635fc40431c zh: 6be7c5ae3c86cdcc /agent-note-web-capability-seam-stable-tools-over-multiple-providers/problem: - en: c8b932b02a4e1c80 - zh: 96c466be1a521f3f + en: df1e82a74c42fa1d + zh: 8d5452360a0dec8a /agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision: - en: 2e08d801099cbe18 - zh: 8c5ab162ada9cc41 + en: 7a4921d82cd11749 + zh: 6ab506109afc1351 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology: - en: 5ea3ddfae82df181 - zh: 2d05d95e51081696 + en: 3394139c17d28ba8 + zh: 66857733ba51d98c /agent-note-web-capability-seam-stable-tools-over-multiple-providers/ctx-web-contract: en: b432f80a424c3e49 zh: faa26925fdd98387 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection: - en: 4e4ec068c2a22383 - zh: b7030d0f00bfdb74 + en: afc540d986e00d68 + zh: 84795770d00228c3 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema: - en: ec3703b7e7c6b23d - zh: 325755e6e90092da + en: ccf6404ae7f3172e + zh: 3253035ea49e2548 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema: en: 6dba3b9a51c28b6d zh: 58a93f6ba2d269ea @@ -33,8 +33,8 @@ en: 3440f2b34b93f4a1 zh: 57a8f784c7e26eff /agent-note-web-capability-seam-stable-tools-over-multiple-providers/testing: - en: 7f73699ac4459432 - zh: c16be49ac50c34c8 + en: 03da2f3422183384 + zh: 10fede51a415ce11 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered: en: 6119e2aff3b6be5a zh: 76136ef5249c23eb @@ -42,8 +42,8 @@ en: e0d84a9b1b9c2123 zh: 9e116a7b96751563 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/put-provider-dispatch-directly-in-dsh-tool-web: - en: dc01d014d03b49d2 - zh: b956b02f41c62682 + en: 68ef45e8e5a36de9 + zh: 0013786ed77e03a8 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/split-search-and-fetch-into-two-seams-dsh-search-dsh-fetch: en: 3fb72ba3e554f20a zh: aff5f0add5773388 @@ -63,11 +63,11 @@ en: 3ea94ffb142d144a zh: 89645f68412f6769 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences: - en: 9774448fd6b21c41 - zh: 8411c590ad70d5c4 + en: 8e7523b2858efaf3 + zh: aff8fc74eb439ed6 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work: - en: 48b7b8e7310cd56b - zh: d406cd6a8a649e58 + en: ddb599fd2ac1647c + zh: c0b8f446cf483b3c /agent-note-web-capability-seam-stable-tools-over-multiple-providers/open-questions: en: 63848c6ef9a6ab44 zh: 041b340802091e60 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 74e88534ee..b1fe6636ab 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -6,7 +6,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md) ## Problem -The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. +The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: search APIs return deliberately different formats (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`, Perplexity returns a generated answer plus citations, and the DeepSeek Search API returns a flat `results[]` with snippets and summaries), and the normalized web contract must accept each of them without mirroring one vendor. The seam was designed against the Exa and Perplexity formats; neither provider ships, and a plugin can register one. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. @@ -19,7 +19,7 @@ There is also a provider-selection question. Existing `tool-bash` and `tool-fs` Web access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors. -2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-http`. +2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-deepseek`, `@deepseek-ai/dsh-web-deepseek-api`, and `@deepseek-ai/dsh-web-fetch-http`. 3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`. Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. @@ -32,9 +32,9 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov - A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. - The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run. -This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` or `web_fetch` while the selected provider reports that the request's operation cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. -The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. +The seam deliberately exposes no availability observation — no aggregated capability-status query and no per-provider availability report. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. Provider pickers consume registration, not availability: `listProviders()` and the Remote method `web/listProviders` list every registered provider with its optional `title` and `description` (including unavailable providers), and the payload-free `web/providers-changed` event fires after each registry mutation. The [web provider selection decision](2026-10-29-web-provider-selection.md) records why registration is observable while availability is not. ## Package topology @@ -43,11 +43,9 @@ The three-package Service Definition / Service Provider / Consumer split follows The dependency direction mirrors bash and filesystem: ```text -@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek consumer interface implementation - <--depends on-- @deepseek-ai/dsh-web-search-perplexity - implementation - <--depends on-- @deepseek-ai/dsh-web-search-deepseek + <--depends on-- @deepseek-ai/dsh-web-deepseek-api implementation <--depends on-- @deepseek-ai/dsh-web-fetch-http implementation @@ -57,9 +55,7 @@ At runtime, provider packages register capabilities with `ctx.web`; `tool-web` r ```mermaid flowchart LR - exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] - perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web - deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web @@ -67,9 +63,9 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support, including the Typert protocol for its Remote method. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. -Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. +Provider packages depend on `dsh-web`, Cordis, and their credential/configuration helpers. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. `@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. @@ -78,16 +74,22 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end `ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmRuntime`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: ```ts -import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' +import type { + WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult, +} from '@deepseek-ai/dsh-web' interface WebSearchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } @@ -95,6 +97,7 @@ interface WebFetchProvider { interface WebRuntime { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void + listProviders(): WebProviderList search(request: WebSearchRequest, signal?: AbortSignal): Promise fetch(request: WebFetchRequest, signal?: AbortSignal): Promise @@ -131,18 +134,15 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: exa + searchProvider: deepseek-search-api fetchProvider: http -- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' - -- id: web-search-perplexity - name: '@deepseek-ai/dsh-web-search-perplexity' - - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' @@ -150,9 +150,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme name: '@deepseek-ai/dsh-tool-web' ``` -Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`. +Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` is equivalent to config `searchProvider: deepseek-search-api`, not a hidden priority chain inside `dsh-tool-web`. -Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once. +Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Search and Fetch menus on the Web search and fetch settings page rely on this to switch each capability's provider independently, including the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md); the [web provider selection decision](2026-10-29-web-provider-selection.md) owns the shipped defaults. `ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider. @@ -168,8 +168,8 @@ The `web_search` model-facing tool is small. The only model-facing argument is: - `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`. - `ctx.web` passes the request through to the selected provider unchanged. -- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization. -- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. +- A provider applies `maxResults` at the request layer when its API supports it (the DeepSeek Search API's `count`), as a cost/latency optimization. +- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly. @@ -190,13 +190,14 @@ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + readonly summary?: string readonly publishedAt?: string } ``` -`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. +`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, summary, and `publishedAt` are optional because not every provider returns them. `snippet` is a short excerpt for display, and `summary` is a longer excerpt for model reading whose length the consumer bounds. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — the DeepSeek Search API returns it as a nullable `date` on each result, Exa as `publishedDate`, and Perplexity as `date`, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. -Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. +The DeepSeek Search API provider maps each entry of the response's `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `snippet`, `summary` ← `summary`, `publishedAt` ← `date`, omitting empty values. It returns no provider-generated answer, so `content` is omitted. If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies. @@ -283,7 +284,7 @@ Tool execution lets these errors flow through `ToolRuntime.execute()`, which alr ## Testing -Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. +Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. ## Alternatives considered @@ -293,7 +294,7 @@ This matches the most flexible provider-plugin systems: every provider can expos ### Put provider dispatch directly in `dsh-tool-web` -This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema. +This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add providers with different formats without baking their differences into the tool schema. ### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`) @@ -321,9 +322,9 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary ## Consequences -**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. +**The search schema is deliberately thin.** Search APIs expose useful provider-specific controls, such as Exa's `livecrawl`, Perplexity's search recency, and the DeepSeek Search API's `localization`; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. -**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. +**Citations can be sparse.** A Perplexity-style citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. **Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool. @@ -337,7 +338,7 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary - A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. - Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. -- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. +- Provider-neutral search controls beyond `query` and `maxResults`, once the registered providers can honor them honestly. ## Open questions diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index ac5f935c92..7977f68d34 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 web 约定并非只是镜像某一家厂商。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 +harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:各搜索 API 返回刻意不同的格式(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表;DeepSeek Search API 返回带 snippet 与摘要的扁平 `results[]`),归一化的 web 约定必须能接纳每一种格式,而不是镜像某一家厂商。seam 是以 Exa 和 Perplexity 的格式为参照设计的;这两个提供方都不随附,插件可以注册它们。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 面向模型的 API 必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。 @@ -19,7 +19,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某 Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 -2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。 +2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-deepseek`、`@deepseek-ai/dsh-web-deepseek-api` 和 `@deepseek-ai/dsh-web-fetch-http`。 3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、提示词段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。 提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。 @@ -32,9 +32,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 - 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。 - 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。 -这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 +这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。`dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的操作无法鉴权时,它会[撤下 `web_search` 或 `web_fetch`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 -该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 +该 seam 刻意不暴露可用性观察——没有聚合的能力状态查询,也没有逐个提供方的可用性报告。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。提供方选择器消费的是注册情况而非可用性:`listProviders()` 与 Remote 方法 `web/listProviders` 列出每个已注册提供方及其可选的 `title` 与 `description`(包括不可用的提供方),且每次注册表变更后都会触发无载荷的 `web/providers-changed` 事件。[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)记录了为何注册情况可观察而可用性不可观察。 ## 包拓扑 @@ -43,11 +43,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 依赖方向与 bash 和 filesystem 一致: ```text -@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek consumer interface implementation - <--depends on-- @deepseek-ai/dsh-web-search-perplexity - implementation - <--depends on-- @deepseek-ai/dsh-web-search-deepseek + <--depends on-- @deepseek-ai/dsh-web-deepseek-api implementation <--depends on-- @deepseek-ai/dsh-web-fetch-http implementation @@ -57,9 +55,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 ```mermaid flowchart LR - exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] - perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web - deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web @@ -67,9 +63,9 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。 +`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持,其中包括其 Remote 方法所用的 Typert 协议。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。 -提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 +提供方包依赖 `dsh-web`、Cordis 及其凭据和配置函数。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 `@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。 @@ -78,16 +74,22 @@ flowchart LR `ctx.web` 是一个提供方注册表加上一个带提供方选择的执行 API。注册表部分与 `LlmRuntime` 保持接近:每种能力类别一个 `Map`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状: ```ts -import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' +import type { + WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult, +} from '@deepseek-ai/dsh-web' interface WebSearchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } @@ -95,6 +97,7 @@ interface WebFetchProvider { interface WebRuntime { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void + listProviders(): WebProviderList search(request: WebSearchRequest, signal?: AbortSignal): Promise fetch(request: WebFetchRequest, signal?: AbortSignal): Promise @@ -131,18 +134,15 @@ interface WebRuntime { - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: exa + searchProvider: deepseek-search-api fetchProvider: http -- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' - -- id: web-search-perplexity - name: '@deepseek-ai/dsh-web-search-perplexity' - - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' @@ -150,9 +150,9 @@ interface WebRuntime { name: '@deepseek-ai/dsh-tool-web' ``` -运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。 +运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` 等同于配置 `searchProvider: deepseek-search-api`,而非 `dsh-tool-web` 内部的隐式优先级链。 -每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。 +每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即可让下一次操作选用新提供方。「网页搜索与获取」设置页的 Search 和 Fetch 下拉框借此分别切换各能力的提供方(包括 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md));[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)负责内置默认值。 `ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。 @@ -168,8 +168,8 @@ interface WebRuntime { - `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。 - `ctx.web` 将请求原样传递给选定的提供方。 -- 当提供方的 API 支持结果数量控制时(Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 -- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制(Perplexity)或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 +- 当提供方的 API 支持结果数量控制时(DeepSeek Search API 的 `count`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 +- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。 @@ -190,13 +190,14 @@ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + readonly summary?: string readonly publishedAt?: string } ``` -`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它,Perplexity 在搜索结果上返回 `date`,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 +`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet、summary 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`snippet` 是用于展示的短摘录,`summary` 是供模型阅读的较长摘录,其长度由消费方限制。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——DeepSeek Search API 在每条结果上以可为 null 的 `date` 返回它,Exa 以 `publishedDate` 返回,Perplexity 以 `date` 返回,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 -Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet,被丢弃)、`publishedAt` ← `publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`(常为空)、`publishedAt` ← `date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 +DeepSeek Search API 提供方将响应 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`、`summary` ← `summary`、`publishedAt` ← `date`,空值省略。它不返回提供方生成的回答,因此 `content` 省略。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。 @@ -283,7 +284,7 @@ fetch 提供方的资源控制: ## 测试 -每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射,加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 @@ -293,7 +294,7 @@ fetch 提供方的资源控制: ### 将提供方调度直接放在 `dsh-tool-web` 中 -这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。 +这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将各提供方格式差异烘焙进工具 schema 的情况下难以添加格式不同的提供方。 ### 将搜索和 fetch 拆为两个 seam(`dsh-search`、`dsh-fetch`) @@ -322,9 +323,9 @@ fetch 提供方的资源控制: ## 后果 -**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 +**搜索 schema 刻意精简。** 各搜索 API 暴露了有用的提供方特有控制,例如 Exa 的 `livecrawl`、Perplexity 的搜索时效性和 DeepSeek Search API 的 `localization`;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 -**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 +**引用可能稀疏。** 一条 Perplexity 风格的引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 **稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当明确暴露结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,避免用户直到模型调用工具后才发现配置问题。 @@ -340,7 +341,7 @@ fetch 提供方的资源控制: - `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 +- `query` 和 `maxResults` 之外的提供方无关搜索控制,待已注册的提供方都能诚实遵守时再添加。 ## 开放问题 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 061ddeed50..95484d284b 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -21,8 +21,8 @@ en: 985e0eb63f3d1fd6 zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: 9a4ba22142a7be24 - zh: 8553b02a923641a3 + en: 4a55602284b462e8 + zh: 762c4f8b719f179c /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: en: b2a740a1ab7a5002 zh: a48be6ca6fc65912 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 34d346c96a..a70ab115be 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -48,7 +48,7 @@ The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed de ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → reject missing direct dependency directories, verify the deployed bootstrap, and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea --compress Brotli` once per target, where the vendored pkg patch pins Brotli's quality to 6: pkg passes no options, so its default quality 11 measured 132.8 s against 3.2 s at quality 6 for an archive 12% larger → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container and copies the resulting addon into the deployed tree. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: the dedicated deploy lockfile retains the source lockfile’s versions and peer settings; [`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) preserves exact local build approvals; the [locked deployment decision](../bug-fix/2026-11-01-locked-python-runtime-deploy.md) supersedes the legacy deployment requirement and disabled-peer rationale; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 6e1e2c51b6..e1ff276504 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -48,7 +48,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.pnpmfile=scripts/python-runtime-deploy.pnpmfile.mjs --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 拒绝缺失的直接依赖目录,验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea --compress Brotli`,其中 vendor 的 pkg 补丁把 Brotli 质量固定为 6:pkg 不传参数时默认质量 11,同一载荷实测 132.8 s,质量 6 为 3.2 s,产物大 12% → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,并将生成的 addon 复制到部署树中。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:独立部署锁文件保留源锁文件的版本及对等依赖设置;[`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) 保留精确的本地构建审批;[按锁文件部署的决策](../bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md)替代 legacy 部署要求及关闭对等依赖的理由;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index 2ce9443416..4d52ae67d8 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -18,11 +18,11 @@ en: ce28268021a38b89 zh: 7802540e4ef04752 /agent-note-the-slot-system-standard-single-register-four-props-shares-and-the-framework-store-seat/decision/component-props-four-shares-each-from-its-own-source-of-truth: - en: f3fca569fa0c5168 - zh: 647a82662f6a887d + en: c1892cb7309c0824 + zh: 21983594d0c7f24f /agent-note-the-slot-system-standard-single-register-four-props-shares-and-the-framework-store-seat/decision/the-chain-kind-entries-self-nominate-first-match-renders: - en: 56278be7f3688805 - zh: 16602ffbf4baef85 + en: dad15e2e1309c9a4 + zh: ee2d13b27c92a73b /agent-note-the-slot-system-standard-single-register-four-props-shares-and-the-framework-store-seat/decision/the-store-seat-framework-engine-registrant-schema: en: 2645acc9d29c20ec zh: a8a2fd293ef5da04 diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index b08c0f4be8..2a2cee6b84 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -50,12 +50,13 @@ A contributor whose activation order is independent from the declaring entry use | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | | business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use` selector hooks (`InjectFace`) | +| kind-specific | `MatchedShare` / `HocShare` | SlotMap entry's kind | chain `matched`; hoc `children`, the next inner layer or the owner content | `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API. ### The chain kind: entries self-nominate, first match renders -The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`). +A fifth kind, `'hoc'`, nests entries around owner-supplied `children` and adds a kind-specific `children` prop the same way `chain` adds `matched`; its decision lives in the [hoc slot kind note](2026-11-03-hoc-slot-kind.md). The fourth `SlotKind`, `'chain'`, inverts routing authority relative to `keyed`: a keyed dispatch site picks its occupant by `entryKey`, while a chain entry nominates itself — the owner dispatches one common currency of owner props and never learns who takes over, so a new takeover package registers with zero owner edits. A chain registration carries a `select` pure selector (`ChainSelect`: `(owner) => matched | null`) and an optional `priority` (ascending; ties keep registration = assembly order — the deployment-controllable inject topology — under the same stable sort as list `order`); registering without `select` is one of the loud-at-load cases above. At render, the outlet runs the selectors in chain order: the first non-null return elects its entry and the returned value joins the component's props as `matched` (the component never re-derives its own match), `null` passes the turn to the next entry, and all-null renders the owner's fallback body (`ChainRenderOpts`). The decline decision lives in `select`, never in a mounted component probing its own props: a component that mounts only to render null still runs its hooks and effects for nothing, and the resulting mount/unmount churn breaks memoization and React key semantics, whereas a selector is a pure function — unit-testable, zero mount side effects — the same discipline as "presentation methods are pure functions of `args`". Purity is the selector's contract: it reads no external mutable state and produces no side effects, so the routing decision is entirely a function of the owner props and safe to run on every dispatch. Selectors route; they never mint — per-dispatch object construction would churn identity every render, so wrapping a matched value in a richer face happens inside the elected component (`useMemo` keyed on `matched`). diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index d36cce53b7..a144d51683 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -50,12 +50,13 @@ ctx.slots.register({ | 子 slot 渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector 钩子 + `actions.*`(剥去 draft 形参) | | 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留的 `hooks` 区域内,裸 observable 经绑定后以 `use` 选择器钩子的形式到达(`InjectFace`) | +| 按 kind | `MatchedShare` / `HocShare` | SlotMap entry 的 kind | chain 的 `matched`;hoc 的 `children`,即内一层或宿主内容 | 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双重类型约束的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 ### chain kind:entry 自荐,首个匹配项负责渲染 -第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占用 slot 的 entry,chain 则由 entry 自荐——owner 只分派一套格式统一的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。 +第五种 kind `'hoc'` 把 entry 层层嵌套在 owner 提供的 `children` 外面,并像 `chain` 提供 `matched` 那样提供按 kind 的 `children` prop;其决策见 [hoc 插槽类型 Note](2026-11-03-hoc-slot-kind.zh.md)。第四种 `SlotKind`——`'chain'`——把路由权相对 `keyed` 反转:keyed 的分派现场以 `entryKey` 点选占用 slot 的 entry,chain 则由 entry 自荐——owner 只分派一套格式统一的 owner props,永远不知道谁来接管,新的接管包注册进来 owner 零改动。chain 注册携带一个 `select` 纯选择器(`ChainSelect`:`(owner) => matched | null`)与可选的 `priority`(升序;同值保持注册序 = 装配序——部署可控的 inject 拓扑——复用 list `order` 的同一稳定排序);注册缺 `select` 即上文装载即炸情形之一。渲染时 outlet 按链序依次执行各 select:首个非 null 返回值当选,该值以 `matched` 并入组件 props(组件绝不自行重新推导匹配);返回 `null` 则轮到下一个 entry;全 null 则渲染 owner 的 fallback 体(`ChainRenderOpts`)。 「不接」的判定住在 `select` 里,绝不在挂载后的组件里自探 props:组件为了渲染 null 也得先挂载,其钩子与 effect 全部白跑,随之而来的挂载/卸载抖动还会破坏 memo 化与 React key 语义;而选择器是纯函数——可单测、零挂载副作用——与「presentation methods are pure functions of `args`」是同一条纪律。纯,就是选择器的约定:不读外部可变状态、不产副作用,路由判定因此完全是 owner props 的函数,每次分派都可安全执行。选择器只做路由,绝不创建新对象——按分派逐次构造对象会让引用每次渲染都换新;把匹配值包成更丰富的面这件事,发生在当选组件内部(以 `matched` 为依赖的 `useMemo`)。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index ceccd5ead5..a41dd3bd98 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -21,8 +21,8 @@ en: 0a4240d88cca240a zh: 8f5f1527caf571cd /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end: - en: 15fb56befd328339 - zh: 0ffb10ada7e52805 + en: dd05aa1ee608c414 + zh: 2863ced0086e3122 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation: en: 37e2fa1fe5cb2b95 zh: 80e513db87709cb4 @@ -33,8 +33,8 @@ en: 94ca0d09e6fd1e3a zh: d5949a2bdec795ba /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences: - en: 16e1f2467c559315 - zh: a714ec6ff2722cf1 + en: 624efdd136029771 + zh: 7d60207147e631fe /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered: en: b8f036f50cff7cd8 zh: 6d623c4a1f4314b1 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 28d718586b..15c0a39295 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -67,8 +67,8 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser. 2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry. 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting. -4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor. -5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. +4. `settled` = every entry created + `loader.await()` quiescent + the boot audit. The audit lists each import-failed, FAILED, or PENDING fiber with its activation error or missing services. It exists because cordis inject waits have no timeout. An inactive entry on the browser required list rejects startup; other inactive entries produce one console warning ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). +5. The framework-free loading page projects real fiber states via `internal/status`. After the audit accepts the tree, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. ### Live graph reconciliation @@ -104,7 +104,7 @@ The current package inventory and build forms live in the [client shell layering One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references. -Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. +Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the boot audit; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 1bf50a15ba..95333823eb 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -67,8 +67,8 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。 2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。 -4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。 -5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 +4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 启动审计。审计列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其激活错误或缺失的服务。它存在的理由:cordis 的 inject 等待没有超时。浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。 +5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。审计接受插件树后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 ### 动态图对账 @@ -104,7 +104,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。 -接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 +接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在启动审计时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index bdd096c69d..ef8926e7d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -12,8 +12,8 @@ en: aea80ece13f2905b zh: a880bc1a7839cf12 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/consequences: - en: 640b7ee4e8c57ed0 - zh: 1c17b3fba16861a8 + en: 41ebe22077560090 + zh: da3475e4c196c3f9 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/alternatives-considered: en: ac43499cfe7b8495 zh: e5ab599e7d3ed753 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index ee4ea2a5b9..aeb9a25081 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -58,7 +58,6 @@ The line is that these take effect with no user action, before any turn, outside - A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. -- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 024c93d3d1..36c3dda624 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -59,7 +59,6 @@ inherited process environment (read-only, wins) - 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 -- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 30244c0085..fb1ee4e295 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -9,11 +9,11 @@ en: fca00eeb7a8d9abd zh: d7a9766f5322dff9 /agent-note-session-projections-as-a-required-reader-seam/decision: - en: 8d38ed9356085c7e - zh: e15ede51415192f3 + en: ed3ee779d31afdba + zh: 2ffad21003be59c5 /agent-note-session-projections-as-a-required-reader-seam/alternatives-considered: - en: aea3da939c90d240 - zh: 21e91c481cdd23b7 + en: 778e12e73a9a6900 + zh: 5d4f313e4ea87372 /agent-note-session-projections-as-a-required-reader-seam/consequences: - en: ab45eb87f7c22cdb - zh: e65a97991a45164f + en: b1935fb4cc8d9cc6 + zh: 0611d7033f5702a3 diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md index 091e8b277a..3256c6dbb0 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md @@ -18,6 +18,8 @@ Owners acquire state through [awaited registration and retained handles](2026-10 `onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. +`onEventChanged` groups the changes of one committed event. After every unit has been driven, it receives one `{ key, val }` entry per client-visible key whose view changed, in registration order, where `val` is the key's view. If a unit or an `onChanged` listener throws, it still receives the entries collected before the throw. Remote projection frames follow this grouping: `session/control` and `session/observeProjections` emit one frame per committed event with a `changes` array of the same entries, so a Client applies one event's changes and publishes its Session list once instead of once per key. Each entry names its key, so a key changed to `null` stays distinct from an unchanged key and no consumer pairs values with keys by position. `onChanged` remains for Host consumers that react to one key. + ## Alternatives considered - **Default missing projected state.** This preserves more partial compositions but makes missing host state indistinguishable from a valid empty value. Rejected because official profiles mount the registry and configuration errors must fail explicitly. @@ -25,9 +27,14 @@ Owners acquire state through [awaited registration and retained handles](2026-10 - **Use `snapshot()` for every read.** This keeps one method but computes unrelated wire views and encourages consumers to depend on batch transport data for host logic. Rejected in favor of typed single-key state reads. - **Send full host values to clients.** This avoids separate view types but exposes producer identities and policy knobs that no client consumes. Rejected in favor of explicit cropped views. - **Broadcast registry additions and removals across Host and mux streams.** The streams have no shared ordering, so clients need tombstones, buffered frames, and baseline retries to reconcile them. Rejected because plugin-key churn does not justify a second synchronization protocol. +- **Coalesce per-key frames in the controller or Client.** Microtask batching or grouping frames by sequence number needs buffering and timing assumptions, while the registry already knows where each event's drive ends. Rejected in favor of grouping at the drive. +- **Carry frame changes as a key-to-value object.** Consumers would infer the changed keys from object members and lose the registration order. Rejected in favor of an ordered array of `{ key, val }` entries. +- **Carry parallel `keys` and `values` arrays.** This avoids repeating the entry field names, but every consumer must keep the two arrays aligned by index, and the wire types cannot express their equal length. Rejected in favor of self-contained entries. +- **Replace `onChanged` with the event-level feed.** Host consumers such as forge react to one key and gain nothing from grouping. Rejected; both feeds remain. ## Consequences - Missing projection composition fails during activation; it never degrades to a default value. - Host consumers avoid repeated whole-registry snapshots and log scans. - Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set. +- One committed event produces at most one Remote projection frame per Session, whose `seq` is that event's sequence number. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index c30f7bd35d..26644a05d9 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -18,6 +18,8 @@ Status: implemented `onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 +`onEventChanged` 把一个已提交事件的变化归为一组。所有单元驱动完成后,它按注册顺序收到视图发生变化的每个客户端可见 key 一项 `{ key, val }`,`val` 是该 key 的视图。某个单元或 `onChanged` 监听器抛错时,它仍会收到抛错前已收集的各项。Remote projection 帧沿用这一分组:`session/control` 与 `session/observeProjections` 每个已提交事件发出一帧,携带由同样各项组成的 `changes` 数组,因此 Client 一次应用一个事件的变化,Session 列表只发布一次,而不是每个 key 一次。每项都写明自己的 key,因此改为 `null` 的 key 与未变化的 key 可以区分,消费方也不必按位置把值和 key 配对。`onChanged` 保留给只关心单个 key 的 Host 消费方。 + ## 考虑过的替代方案 - **为缺失的投影状态提供默认值。** 这会保留更多不完整组合,但缺失 host 状态将无法与合法空值区分。正式 profile 已挂载注册表,配置错误必须显式失败,因此否决。 @@ -25,9 +27,14 @@ Status: implemented - **每次读取都使用 `snapshot()`。** 这只保留一个方法,但会计算无关 wire view,并鼓励消费方让 host 逻辑依赖批量传输数据。改用类型化单 key 状态读取。 - **向客户端发送完整 host 值。** 这避免单独的 view 类型,但会暴露客户端不消费的来源信息和策略旋钮。改用显式裁剪的 view。 - **跨 Host 和 mux stream 广播注册表新增和移除。** 两条 stream 没有共享顺序,客户端因此需要 tombstone、缓冲帧和基线重试来协调。插件 key 变化不值得引入第二套同步协议。 +- **在 controller 或 Client 中合并逐 key 帧。** 按 microtask 攒批或按序号分组都需要缓冲和时序假设,而注册表本来就知道每个事件的驱动在哪里结束。改为在驱动处分组。 +- **把帧内的变化做成 key 到值的对象。** 消费方要从对象成员推断哪些 key 变化,也会丢失注册顺序。改用有序的 `{ key, val }` 数组。 +- **使用平行的 `keys` 与 `values` 两个数组。** 这样不必重复每项的字段名,但每个消费方都要按下标保持两个数组对齐,线路类型也无法表达两者等长。改用自带 key 的各项。 +- **用事件级变更流取代 `onChanged`。** forge 等 Host 消费方只关心单个 key,分组对它们没有好处。两条变更流都保留。 ## 后果 - 缺少投影组合会在激活期间失败,绝不会降级为默认值。 - host 消费方避免重复的全注册表快照和日志扫描。 - 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。 +- 一个已提交事件对每个 Session 至多产生一个 Remote projection 帧,其 `seq` 是该事件的序号。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index c67dff0722..0bb6fa05e0 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -30,8 +30,8 @@ en: efded09f0cd30a8e zh: 5e16580a39ff3359 /agent-note-session-observations-and-projection-owned-client-state/decision/client-merge-rules: - en: fb8740be674e4708 - zh: 7dc0c89558f51792 + en: 135a93a2acecaf2a + zh: 87b69116a64b80b5 /agent-note-session-observations-and-projection-owned-client-state/decision/domain-applications: en: 4dcae9e00148b11e zh: acfd1b6831cf50fe diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 530aeb36e1..f10764daab 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -106,7 +106,7 @@ These distinctions prevent one overloaded `undefined` from representing cache mi |---|---|---|---| | Session list hints | Partial | Last durable checkpoint or bounded fallback cut | Unknown | | Follow opening baseline | Complete for the Host composition | Exact opening cursor | Capability absent | -| Projection frame | One whole key | Event sequence carried by the frame | Not applicable | +| Projection frame | Every key the frame lists, each a whole value | Event sequence carried by the frame | Unchanged | The Client stores one row per key. A row the connected Host computed carries its sequence number: a newer baseline or frame replaces it, an equal or older input is ignored, and reconnect can therefore replace the event window without rolling back a projection frame that was already accepted at a later sequence. A list hint viewed from the persisted cache carries no comparable sequence number and yields to every Host-sequenced write ([projection cache listing identity and cached rows](2026-09-19-projection-cache-listing-identity-and-cached-rows.md)). diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 62ac9db852..96b4a70242 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -106,7 +106,7 @@ Projection 的三种交付状态含义不同: |---|---|---|---| | Session list hints | 部分 | 上次持久 checkpoint 或有界 fallback cut | 未知 | | Follow opening baseline | 对当前 Host composition 完整 | 精确 opening cursor | Capability 不存在 | -| Projection frame | 单个完整 key | Frame 携带的 event sequence | 不适用 | +| Projection frame | 帧列出的每个 key,各为完整值 | Frame 携带的 event sequence | 未变化 | Client 为每个 key 保存一行。由建连 Host 算出的行带 sequence number:更新的 baseline 或 frame 会替换它,相同或更旧的输入被忽略,因此 reconnect 可以替换 event window,而不会回退已经在更晚 sequence 接受的 projection frame。从持久化缓存看出来的 list hint 不带可比的 sequence number,让位于所有 Host 排序过的写入([Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md))。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 260adf6610..97e7927b58 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md /agent-note-adjacent-agents-share-one-steer-send-message-operation: - en: 758d31ae04ce0d4f - zh: 112475efbd52b9f6 + en: 9d0af2bae38b1eec + zh: 8a6e8502a5195548 /agent-note-adjacent-agents-share-one-steer-send-message-operation/problem: - en: c988b8a3be4e6f19 - zh: b58011fd8d2e878e + en: 5dc4c218f648c27e + zh: b48531fac94b25db /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision: - en: 1188c699ce6e6ca2 - zh: 4d5b974cd1216f06 + en: f4789878ac0e79b2 + zh: 68eb68d34cba810d /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction: - en: 4b0a25389324fa91 - zh: c2cdd9f7e40ef43b + en: 62284bd7dd804188 + zh: b3a8dfebffc01bdb /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition: - en: 5a9ec6478cf7430c - zh: f4c2f067cc72040e + en: 8c6d355d91ec83d2 + zh: fcc74d1e6ef477bf /agent-note-adjacent-agents-share-one-steer-send-message-operation/alternatives-considered: - en: 1f62d5d938144666 - zh: dec0f30228c5b7a7 + en: 7386e88e7051dce7 + zh: dcea5b9e30374be3 /agent-note-adjacent-agents-share-one-steer-send-message-operation/consequences: - en: d55de74742c3af4b - zh: d894f9a33c8e4691 + en: 9c23dd6ace029ec3 + zh: f08ef3f142534843 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index 4efb9d334e..a4cfbc91cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -4,26 +4,25 @@ Status: implemented English | [中文](2026-08-27-adjacent-agent-steer-messaging.zh.md) +The adjacency, discovery, and parent-lifetime restrictions below are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md); the remaining decisions stay active. The tool identity marker and initial-task communication guidance below are superseded by [tool-owned communication](2026-10-22-subagent-tool-owned-communication.md). + ## Problem Continuable Agents originally used direction-specific model controls. A parent called `send_message({ subagent_id, message })`, which delegated to a FIFO `followup` service operation. A child instead received a child-scoped `report({ output })` tool, a `tool:report` system-prompt section, and deployment-selected quiet or waking delivery. The tools described one adjacent-Agent operation through different schemas, service paths, source attribution, and scheduling. -A continuable child owns its own Session, so its parent does not automatically receive the child's transcript, tool output, or reasoning. The return path must therefore remain explicit and repeatable: a child may send progress before it finishes, remain available after sending, or fail before it can cooperate. Turning every final assistant message into an implicit result would conflate turn completion with model-selected communication and would not cover abnormal endings. +A continuable child owns its own Session, so its parent does not automatically receive the child's transcript, tool output, or reasoning. Explicit messages let a child send actionable findings before finishing. The runtime separately delivers terminal output in a settlement notice when the direct creator is still online. The child-only tool and system-prompt section also preceded every inherited fork turn. They made a continuable fork child's request head differ from its parent's before the history that fork exists to reuse, forcing the provider to prefill the entire copied transcript again. ## Decision -`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target on one adjacent edge: +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target in the same root tree; [tree-wide communication](2026-10-16-tree-wide-agent-communication.md) owns authorization and discovery. -- parent to direct continuable child, authorized by the child's durable `SessionHeader.parentSession`; -- resident continuable child to its exact live direct parent, authorized by the child's Activation. +Self-targets, stale Agent objects, unknown targets, and external executions are not alternate routes. The operation has no caller-supplied source, delivery mode, offline root mailbox, or provider dispatch. -Siblings, self-targets, ancestors beyond one edge, stale Agent objects, unknown targets, and one-shot children are not alternate routes. The operation has no caller-supplied source, delivery mode, offline parent mailbox, or provider dispatch. +Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent target is cold-resumed under the root before the same Steer delivery; its creation ancestors can remain offline. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. -Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent direct child is cold-resumed through the existing continuation lifecycle before the same Steer delivery. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. - -Every direction uses one durable source. The service derives `senderSessionId` from the authorized Agent and frames the model-visible content as `Agent sent a message:`, so attribution cannot diverge from authority. +Every direction uses one durable source. The service derives `senderSessionId` from the exact live sender and frames content as `Agent sent a message:`. ```ts import type { SessionId } from '@deepseek-ai/dsh-session' @@ -46,15 +45,15 @@ interface SendMessageInput { } ``` -Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. +Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; none receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id, automatic final-answer delivery conditions, and guidance to send earlier actionable findings to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. -The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate. +The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The separately attributed `subagent-settled` notice records the Activation's outcome and terminal output only when its direct creator is still online and delivery is `parent`. Human browser prompts are not model-authored Agent messages. The remote prompt path keeps a private Queue delivery so each human prompt remains a distinct turn. Interrupt behavior and settlement delivery remain independent. ### Complete removal and reintroduction condition -The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that an adjacent `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. +The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that a tree member’s `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. ## Alternatives considered @@ -64,16 +63,16 @@ The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, **Make `report` global.** Roots, one-shot children, remote children, and agentless callers cannot derive a report recipient. Advertising it globally would make schema visibility disagree with authority, while `send_message` already makes the recipient explicit. -**Turn every child final message into an implicit send.** A long-lived child may have nothing useful to send in one turn and several findings in another. Automatic delivery would merge model-authored communication with the runtime's settlement account and could not replace the unconditional notice on errors, cancellation, or token exhaustion. +**Treat the final answer as a model-authored send.** A settlement notice must preserve terminal output and abnormal endings even when the child cannot cooperate. It keeps runtime attribution distinct from findings the child explicitly chose to send. -**Rely only on the tool description.** A tool description helps after the model considers that tool; the failure mode is a child that believes it is finished without considering any return call. Initial-task guidance reaches that decision without changing the inherited system or tool prefix. +**Rely only on the tool description.** A tool description helps after the model considers that tool; initial-task guidance also explains which earlier findings should reach the parent and what the final answer must contain, without changing the inherited system or tool prefix. **Keep quiet delivery as deployment policy.** A quiet model-authored message can be accepted while an idle target never reads it. Fixed Steer gives both directions one delivery meaning and preserves accepted order with later settlement notices. ## Consequences - Model consumers expose one `send_message({ agent_id, message })` definition to parents and children, with no model-selected Queue versus Steer parameter. -- The continuation manager remains the sole owner of adjacency authorization, residency, cold resume, waking admission, and teardown races. +- The continuation manager remains the sole owner of tree authorization, residency, cold resume, waking admission, and teardown races. - Accepted messages may extend a running target's current turn; messages waiting together share next-step FIFO ordering. - Caller cancellation owns work only until inbox acceptance and does not retract an accepted message or dispose the target. - The initial task carries JSON-encoded dynamic parent addressing after a fork prefix, while the request-head system prompt and tool ordering remain reusable. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index b7ed157f54..d907eaeac0 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -4,26 +4,25 @@ Status: implemented [English](2026-08-27-adjacent-agent-steer-messaging.md) | 中文 +邻接消息、发现范围和父子生命周期限制已由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)替代;其余决策仍然有效。 下文的工具身份标记和初始任务通信指导由[工具拥有通信指导](2026-10-22-subagent-tool-owned-communication.zh.md)取代。 + ## 问题 可继续 Agent 最初使用方向专属的模型控制。parent 调用 `send_message({ subagent_id, message })`,委托给 FIFO `followup` 服务操作。child 则获得 child 作用域的 `report({ output })` 工具、`tool:report` 系统提示词 section,以及由部署选择的静默或唤醒投递。两个工具用不同 schema、服务路径、来源与调度描述同一个相邻 Agent 操作。 -可继续 child 拥有自己的 Session,因此 parent 不会自动收到 child 的 transcript(文本记录)、工具输出或推理。返回路径必须保持显式且可重复:child 可以在结束前发送进度、发送后仍保持可用,也可能在来得及配合前失败。把每条最终 assistant 消息变成隐式结果会混淆轮次完成与模型选择的通信,而且无法覆盖异常结束。 +可继续 child 拥有自己的 Session,因此 parent 不会自动收到 child 的 transcript(文本记录)、工具输出或推理。显式消息让 child 在结束前发送可操作的发现。直接创建者仍在线时,runtime 另行通过结算通知投递终态输出。 child 专属工具与系统提示词 section 还位于每个继承 fork 轮次之前。它们让可继续 fork child 的请求头在 fork 旨在复用的历史之前就与 parent 不同,迫使提供方重新预填充整份复制 transcript。 ## 决策 -`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender 与一条相邻边上的目标: +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender 与同一根树中的目标;[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)负责授权与发现规则。 -- parent 到直接可继续 child,由 child 的持久化 `SessionHeader.parentSession` 授权; -- 驻留的可继续 child 到其确切在线直接 parent,由 child 的 Activation 授权。 +自身目标、过期 Agent 对象、未知目标和外部执行不是替代路由。操作不接受调用方提供的 source、投递模式、离线根邮箱或提供方分发。 -sibling、自身目标、超过一条边的 ancestor、陈旧 Agent 对象、未知目标与一次性 child 都不是替代路由。该操作没有调用方提供的 source、投递模式、离线 parent mailbox 或提供方分发。 +每条被接受的消息都使用 `Agent.steer()`。运行中目标在最近 step 边界接收消息;空闲目标启动轮次。不在线目标先在根下冷恢复,再接受同一 Steer 投递;其创建祖先可以保持离线。管理器保留唤醒发送记账,因此受继续执行管理的目标不会在同步 inbox 插入与 driver 准入之间结算。 -每条被接受的消息都使用 `Agent.steer()`。运行中目标在最近 step 边界接收消息;空闲目标启动轮次。缺失的直接 child 会先通过现有继续执行生命周期冷恢复,再接受同一 Steer 投递。管理器保留唤醒发送记账,因此受继续执行管理的目标不会在同步 inbox 插入与 driver 准入之间结算。 - -两个方向使用同一种持久来源。服务从已授权 Agent 推导 `senderSessionId`,并把模型可见内容组装为 `Agent sent a message:`,因此来源信息不会偏离权限。 +所有方向使用同一种持久 source。服务从确切在线发送方推导 `senderSessionId`,并以 `Agent sent a message:` 包装内容。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session' @@ -46,15 +45,15 @@ interface SendMessageInput { } ``` -parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 +parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;这些情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、最终回答的自动投递条件,以及提前发送可操作发现的指导追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 -该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。 +该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。采用独立来源的 `subagent-settled` 通知仅在直接创建者仍在线且投递方式为 `parent` 时,记录 Activation 的结果与终态输出。 浏览器中的人类提示不是模型编写的 Agent 消息。远程提示路径保留私有 Queue 投递,使每条人类提示保持为独立轮次。中断行为与结算投递保持独立。 ### 完整移除与重新引入条件 -独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要相邻 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 +独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要树成员的 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 ## 考虑过的替代方案 @@ -64,16 +63,16 @@ parent 与 child 以相同注册表顺序继承相同定义。标准定义携带 **让 `report` 全局可见。** 根 Agent、一次性 child、远程 child 与无 Agent 调用方无法推导 report 接收方。全局宣传它会让 schema 可见性与权限不一致,而 `send_message` 已显式给出接收方。 -**把每条 child 最终消息变成隐式发送。** 长期运行的 child 可能在某个轮次没有值得发送的内容,在另一个轮次却有多条发现。自动投递会混合模型编写通信与 runtime 结算说明,而且无法替代错误、取消或 token 耗尽时的无条件通知。 +**将最终回答当作模型编写的发送。** 即使 child 无法配合,结算通知仍须保留终态输出与异常结束信息。它将 runtime 来源与 child 显式选择发送的发现区分开。 -**只依赖工具描述。** 工具描述会在模型考虑该工具后提供帮助;失败模式是 child 认为自己已经完成而根本没有考虑返回调用。初始任务指导能触及该决策,又不会改变继承的系统或工具前缀。 +**只依赖工具描述。** 工具描述会在模型考虑该工具后提供帮助;初始任务指导还说明哪些早期发现应发送给 parent,以及最终回答须包含什么,又不会改变继承的系统或工具前缀。 **保留静默投递作为部署策略。** 静默的模型编写消息可能被接受,但空闲目标永远不会读取它。固定 Steer 为两个方向提供一种投递含义,并保持与后续结算通知的接受顺序。 ## 后果 - 模型 Consumer 向 parent 与 child 公开一个 `send_message({ agent_id, message })` 定义,不提供模型选择的 Queue 与 Steer 参数。 -- 继续执行管理器仍是相邻关系授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 +- 继续执行管理器仍是整树授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 - 被接受的消息可以延长运行中目标的当前轮次;一起等待的消息共享 next-step FIFO 顺序。 - 调用方取消只在 inbox 接受前掌管工作,不会撤回已接受消息或 dispose(资源释放)目标。 - 初始任务在 fork 前缀之后携带经过 JSON 编码的动态 parent 地址,而请求头系统提示词与工具顺序保持可复用。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index ddf55f9063..f8de1ed3b1 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml @@ -9,14 +9,14 @@ en: 98b49b900b8d2f84 zh: 557faac2976592be /agent-note-explicit-agent-identity-at-runtime-boundaries/decision: - en: 1c628cf9c743c639 - zh: d969b2475fe4a155 + en: 4a3ab5108f37520f + zh: d13fcd448101a95d /agent-note-explicit-agent-identity-at-runtime-boundaries/verification: - en: 85130faaa171036b - zh: 06e5e9fab32dbfb4 + en: ce1733edf03f0387 + zh: 8e1ca2b69e81968e /agent-note-explicit-agent-identity-at-runtime-boundaries/alternatives-considered: - en: 2c41456509d54d21 - zh: 5704ee9a2274e300 + en: 34f5f8571b21897c + zh: cf16094bc0027ca1 /agent-note-explicit-agent-identity-at-runtime-boundaries/consequences: - en: 08ab86b89fc3ec6f - zh: 3866e6557858543a + en: 0b5914067f14282e + zh: c41d21aeb28e357f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 16cd668f65..336151fa83 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -14,34 +14,34 @@ Without an explicit owner, `SubagentManager` creates and resumes children throug ## Decision -Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; each subagent Activation retains its exact runtime root; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. -Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` receives the Agent explicitly and installs through the supplied Context before Agent publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. -`SubagentManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. +`SubagentManager.rootOf(agent)` reads runtime membership from existing Activations; both the child and root must match their live registry objects. Ordinary Sessions resolve to themselves, and unmanaged subagent headers grant no membership. Durable `parentSession` describes creation edges; an Activation retains the exact root while intermediate creators are offline, and same-id replacements cannot inherit it. Root-only consumers classify the explicit subagent origin. The [scope reference](../../../../packages/core/scope/README.md) and [Agent loop reference](../../../../packages/core/agent-loop/README.md) document registration and lifecycle behavior. The [initiator-scope decision](2026-07-15-agent-initiator-scope.md) owns private-chain rationale; explicit runtime ownership does not change causal initiation or registration lifetime. ## Verification -Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. +Managed-child tests verify intermediate-creator disposal and exact-instance checks after child or root replacement. SDK approval tests verify routing during a turn after the direct creator exits. Schedule and user-question tests reject delegated Sessions while ordinary historical forks remain independent. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Agent publication; standing-preset tests verify per-Session policy sampling and inheritance. ## Alternatives considered **Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. -**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the subagent Activation retains the exact root supplied at admission. **Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. **Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. -**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. +**Use durable Session lineage.** `parentSession` records creation ancestry across process lifetimes. An intermediate creator can be offline while its descendant remains managed by a live root; durable ids also cannot distinguish replaced Agent instances. Historical ordinary forks may retain parent metadata without being delegated. ## Consequences Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. -Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. +Continuable children have one runtime root independent of creator residency. The subagent manager owns tree teardown and local membership; the core factory and registry retain only instance creation, publication, and disposal. Third-party callers create managed children through the subagent service rather than assigning ownership to arbitrary factory-created Agents. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 3d4131af01..063f0490bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -14,34 +14,34 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 ## 决策 -运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;每个 subagent Activation 保留其确切运行时树根;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 -感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式接收 Agent,并在 Agent 发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 -`SubagentManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 +`SubagentManager.rootOf(agent)` 从已有 Activation 读取运行时成员关系;子级和树根都必须匹配注册表中的存活对象。普通 Session 返回自身,非托管的 subagent header 不授予成员身份。持久化 `parentSession` 描述创建关系;中间创建者离线时,Activation 保留确切树根,同 id 替代实例不能继承它。仅限根级的消费方依据显式 subagent 来源分类。 [作用域参考](../../../../packages/core/scope/README.zh.md)与 [Agent loop 参考](../../../../packages/core/agent-loop/README.zh.md)说明注册和生命周期行为。[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)负责私有调用链理由;显式运行时归属不改变因果发起关系或注册生命周期。 ## 验证 -Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 +托管子级测试验证中间创建者释放,以及子级或树根替换后的确切实例检查。SDK 审批测试验证直接创建者退出后的轮次内路由。Schedule 与用户问题测试拒绝委派 Session,历史普通 fork 则保持独立。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Agent 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 ## 考虑过的替代方案 **保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 -**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;subagent Activation 保留准入时传入的确切树根。 **分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 **使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 -**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录创建祖先关系。中间创建者可以离线,而后代仍由存活树根管理;持久 id 也无法区分替换后的 Agent 实例。历史普通 fork 可能保留父级元数据,但并不属于委派。 ## 后果 生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 -可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 +可续跑子级拥有独立于创建者驻留状态的唯一运行时树根。subagent 管理器拥有整树 teardown 和本地成员关系;核心工厂与注册表仅保留实例创建、发布和释放。第三方调用方通过 subagent 服务创建托管子级,而非给任意工厂创建的 Agent 指定归属。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index f7082b3940..954230cc2a 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml @@ -9,8 +9,8 @@ en: 4548e7fbdbee5b5a zh: 0078762ea92fa180 /agent-note-consumer-owned-startup-strictness/decision: - en: 1f11e04162e19ad1 - zh: d102e300eba9a2ea + en: 62e93742af5bed66 + zh: 3944d685b6503a2c /agent-note-consumer-owned-startup-strictness/alternatives-considered: en: 1b67e062213184cd zh: 91cc8a015de2bfe4 @@ -18,5 +18,5 @@ en: 283dec1b336c540d zh: 2ed358079072cfff /agent-note-consumer-owned-startup-strictness/testing: - en: c38221069ce98fd3 - zh: 9a2f91f539eba4d4 + en: cee14c22297e5471 + zh: a68748cc36f4a574 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index cf17823e72..be4e748975 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -20,6 +20,8 @@ The audit runs only during initial application boot. Later config HMR remains be This policy governs [Web host boot](../../../../packages/boot/app-boot/README.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit. +The browser boot audit applies the same policy to the client plugin tree with its own global list in [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts): the module system, Session control, the renderer, the application frame, the Session view, and its sidebar. Browser entries are named by package, so the list holds package names; a name absent from the boot graph has no effect, and neither list is derived from the other. The list is the minimum fatal set: an entry that a listed entry waits on blocks startup too. Feature plugins, including credential masking, stay optional. A failed entry reports its activation error. Optional failures appear in one console warning, and in Settings → Plugins → Plugin list only after a later graph synchronization reconciles them. Renaming a listed package requires updating the list and its tests. + ## Alternatives considered - **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group. @@ -36,6 +38,8 @@ The compact terminal report keeps the failing plugins visible; a separate file r App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. +Browser audit unit tests (`boot-client.client.spec.ts`, `boot.client.spec.ts`) cover required and optional import, creation, activation, and pending failures, carrier presentation, and required names absent from the boot graph. Test carriers are stricter than the product: `TestClient` requires every roster entry to activate unless a case passes `allowInactiveEntries`, and the browser e2e lanes treat a `web boot: warning` as a failure with no opt-out. + The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../../../../packages/boot/app-boot/tests/user-patches.spec.ts): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index c8bda5cb20..4287b4aad2 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -20,6 +20,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles 该策略适用于 [Web host 启动](../../../../packages/boot/app-boot/README.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。 +浏览器启动审计以同一策略审计客户端插件树,使用 [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts) 中独立的全局名单:模块系统、Session 控制、渲染器、应用框架、Session 视图及其侧栏。浏览器 entry 以包名命名,因此名单写包名;启动图中不存在的名字不产生影响,两份名单互不推导。名单是最小致命集合:已列出 entry 所等待的 entry 同样会阻止启动。功能插件(包括凭据遮挡)保持可选。失败 entry 报告其激活错误。可选失败出现在一条控制台警告中;只有后续图同步调和之后,才会出现在「设置 → 插件 → 插件列表」。重命名名单中的包需要同步更新名单及其测试。 + ## 考虑过的替代方案 - **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。 @@ -36,6 +38,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 +浏览器审计单元测试(`boot-client.client.spec.ts`、`boot.client.spec.ts`)覆盖必需与可选 entry 的导入、创建、激活与 pending 失败、carrier 呈现,以及启动图中不存在的必需名称。测试载体比产品更严格:除非用例传入 `allowInactiveEntries`,`TestClient` 要求每个 roster entry 都激活;浏览器 e2e 把 `web boot: warning` 视为失败,且不提供放宽。 + [Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml index d7f60ead90..a34df7ba86 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.i18n.yaml @@ -15,8 +15,8 @@ en: 3aef37bb4f8a8e53 zh: a7b477324d486ab3 /agent-note-sandboxed-node-execution-for-ptc/decision/control-and-lifetime: - en: 2c7a694b04951772 - zh: 133b95b0477b127f + en: a8897bae00f5f674 + zh: 2c959353c5a885a4 /agent-note-sandboxed-node-execution-for-ptc/decision/resource-limits: en: 3f1d8a30f6d48243 zh: faa7c01d7c959893 diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md index 1259a617ad..91cbbf8159 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.md @@ -28,7 +28,7 @@ The [experimental Python provider](../../../../packages/experimental/ptc-runtime The subprocess owner supplies a dedicated inherited binary control channel, separate from program stdout/stderr and launcher lifecycle IPC. The host bounds frames, queued writes, pending calls and outstanding argument bytes, then validates call identity and the binding allowlist before dispatch. Model code can write to that channel, so its bytes remain untrusted. -Program completion, timeout, cancellation and protocol failure all close execution through the managed process owner. Result selection stops the execution timer; cleanup then waits for the direct outcome and managed-range quiescence. The PTC bridge separately aborts and drains nested tool dispatches before its outer tool result settles. A denial or transport failure never automatically replays a program whose effects may already have occurred. +For temporary executions, program completion, timeout, cancellation and protocol failure all close execution through the managed process owner. Result selection stops the execution timer; cleanup then waits for the direct outcome and managed-range quiescence. The PTC bridge separately aborts and drains nested tool dispatches before its outer tool result settles. A denial or transport failure never automatically replays a program whose effects may already have occurred. ### Resource limits diff --git a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md index 0074fdf578..849737b6dc 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-sandboxed-node-ptc-runtime.zh.md @@ -28,7 +28,7 @@ Host 在子进程启动时保留 `ELECTRON_RUN_AS_NODE`;bootstrap 在求值前 子进程所有者提供专用的继承式二进制控制通道,与程序 stdout/stderr 及 launcher 生命周期 IPC 分开。Host 限制帧、排队写入、待处理调用和未完成参数字节,然后在分派前验证调用身份与绑定允许列表。模型代码可以写入该通道,因此其中字节仍不可信。 -程序完成、超时、取消与协议失败都通过受管进程所有者关闭执行。选择结果后停止执行计时器;清理随后等待直接结果与受管范围停稳。PTC 桥接在外层工具结果结算前,另行取消并排空嵌套工具分派。拒绝或传输失败绝不自动重放可能已经产生副作用的程序。 +对于临时执行,程序完成、超时、取消与协议失败都通过受管进程所有者关闭执行。选择结果后停止执行计时器;清理随后等待直接结果与受管范围停稳。PTC 桥接在外层工具结果结算前,另行取消并排空嵌套工具分派。拒绝或传输失败绝不自动重放可能已经产生副作用的程序。 ### 资源限制 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index 511e290621..e0b3e158eb 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -9,8 +9,8 @@ en: edefff7cd986a509 zh: a270839fa86976a2 /agent-note-guided-plugin-installation/decision: - en: 5448b9f8aaa9a569 - zh: 9024c5328c5da3bd + en: ca0224ae08a4b383 + zh: 780603ca24487949 /agent-note-guided-plugin-installation/alternatives-considered: en: be64b541f4d81e7c zh: 8600531c08ab4288 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index 40cbf6179b..aa62340f84 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -14,7 +14,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store. -**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../../../../packages/boot/plugin-manager/README.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. +**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../../../../packages/boot/plugin-manager/README.md)). The client shows the kind as one line and folds pnpm's output behind the details, which also name the complete log and a failure that stopped before pnpm ran ([details](../bug-fix/2026-10-28-plugin-install-failure-diagnostics.md)). Parsing the log is confined to this one function with a fixture-driven test. **GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index c53c138059..09c2c2e572 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -14,7 +14,7 @@ Status: implemented **失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。 -**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../../../../packages/boot/plugin-manager/README.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 +**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../../../../packages/boot/plugin-manager/README.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情;详情还会指出完整日志,以及尚未运行 pnpm 就停下的一次失败([详情](../bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md))。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 **GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml index a59c5e9371..58293afaff 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml @@ -3,17 +3,17 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md /agent-note-host-owned-scheduled-messages: - en: 11854b220af809ea - zh: 0f0575de37107ed8 + en: b3b0dde47f58ca48 + zh: d4ba3a6cf1228578 /agent-note-host-owned-scheduled-messages/problem: en: 2c774d05ae75784f zh: 619ba7f281b13233 /agent-note-host-owned-scheduled-messages/decision: - en: 1210b3975fd26bb3 - zh: 3322e6d0aa63fdb0 + en: 20dcfc8e9c44cde7 + zh: d9609e41eb4e9bd9 /agent-note-host-owned-scheduled-messages/alternatives-considered: en: 21d7ec9a9ff3cbf2 zh: 93d03b8b3012aa7e /agent-note-host-owned-scheduled-messages/consequences: - en: 3ae0cd93ed95431e - zh: 25a0a2bc25863651 + en: ce4ef02663cd485c + zh: ad8ae567c3bab8e0 diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md index cd295da249..770d1f9291 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md @@ -4,36 +4,20 @@ Status: implemented English | [中文](2026-09-16-host-schedule-storage.zh.md) +This note remains current for Host authority, whole-unit durability, and saved inbox receipts. [Typed rule storage](2026-10-21-schedule-rule-storage.md) and [Session targets](2026-09-17-standalone-schedules.md) replace its original management and target contracts. + ## Problem Scheduled messages must remain discoverable after restart without loading every Session. A timer attached to a live Agent cannot dispatch a task whose Session remains cold. Users also need to inspect pending and delivered tasks without activating their conversation; removing a task after delivery loses that management record. ## Decision -The existing Schedule package owns one Host storage domain containing each task's original Session id, reminder record, `active` or `inactive` status, latest delivery receipt, and saved delivery history. The domain is authoritative for retained tasks. The Host scheduler restores timers only for active tasks; a due task uses the Session controller's existing resolution operation and queues an ordinary follow-up in the original Session. Querying and deleting tasks do not resolve an Agent. Stored records lacking status normalize to `active` without scanning or recreating historical Sessions. +The original `schedule` domain and whole-unit layout remain authoritative; v2 accepts v1. [Typed rule storage](2026-10-21-schedule-rule-storage.md) supersedes the original Session-owned management API, top-level binding writes, and two-state lifecycle; [Session targets](2026-09-17-standalone-schedules.md) owns Host-wide management and delivery targets. The seven normal timing kinds preserve committed targets. An explicit target wins; absent targets read the old Session binding or reserve a stable new-once identity. No startup migration runs. The task domain declares the whole-unit layout. Tasks are authoritative, so the JSON backend rejects an unreadable file, malformed document, unsupported version, or invalid task as a complete load failure instead of interpreting it as deletion. Failed recovery leaves `schedule.json` unchanged, while an initially absent file opens as an empty domain; repairing the file preserves task identity. `Service.init` waits for both storage validation and runtime setup, management methods wait for the same initialization result, and an unloading owner releases late-acquired storage without starting dispatch. Dispatch admission is separate from initialization: an ancestor can close Agent admission after a task mutation commits but before runtime cleanup starts. That refusal is logged without automatic retry and does not turn the persisted mutation into a failed management result. Invalid storage and failed cleanup registration still reject startup. -This replaces the earlier Session-log authority, live-only activation, and read-only catalog decisions. The [Schedule reference](../../../../packages/schedule/schedule/README.md) owns explicit absolute-time validation, fixed-rate arithmetic, and the distinction between queue admission and model completion. Historical `schedule/change` creation and fold types remain restricted to After, At, and Every through `LegacyScheduleRecord`; a separate Host decoder accepts the current `ScheduleRecord`, including Daily, Weekly, and Cron. The Host record decoder requires a stored `title` that is non-empty after trimming, already trimmed, and at most 120 characters, and the strict task schema rejects the whole domain open instead of dropping such a task. The historical change decoder tolerates an absent `title` so an already-written Session log stays readable, and it applies the same three checks when the member is present. The storage domain remains version 1. Historical events do not populate that domain, and existing `at` tasks are not converted into daily or weekly rules. Loading a Session with active legacy reminders warns that they must be recreated. - -A plugin-sourced `followup()` synchronously appends the message to the Session inbox. Successful Session flush acknowledges durable delivery. One task-record put stores `lastDelivery`, appends a delivery record with the occurrence target, acknowledgment time, actual message id, and instruction snapshot, and updates the one-shot's `inactive` status or the recurring task's next target and status. Task state and its saved history therefore share one commit. Inactive tasks remain stored and never schedule another delivery; explicit deletion stops future delivery and removes the row together with its saved records. These records acknowledge inbox delivery, not user receipt or model execution completion. - New tasks initialize an empty `deliveryHistory`. A task without that optional field exposes its existing latest receipt, if any, and marks earlier records unavailable. Reading never rewrites the old task or invents the lost history or instruction snapshot. Its next acknowledged delivery preserves that available receipt and appends the new one. Strict decoding rejects duplicate message ids and disagreement between the latest receipt and the last history entry. History follows append order, not wall-clock sorting, so clock rollback does not reverse acknowledged deliveries. -A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. The fixed text both renderers emit is the [delivered-reminder origin line](2026-09-24-scheduled-message-framing.md) decision. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages. - -Daily is a fourth mutually exclusive creation selector, Weekly a fifth, and Cron a sixth, none a conversion of `every_seconds` or `at`. Weekly selects every local date whose ISO weekday belongs to its stored set; the decoder rejects an unnormalized stored set, and the selector rejects an empty, repeated, out-of-range, or non-integer one. Their first targets are strictly future. Missing local times and dates are skipped; overlaps select the earlier instant once per date. The next target is after the delivery decision and on a local date later than the selected occurrence's date. Only UTC targets are restricted to years 0001–9999; local dates may cross into year 0 or 10000 at the edges. After Session restoration, the runtime rechecks due members against the current clock so a rollback does not admit future tasks. - -The maintained `@js-temporal/polyfill` 0.5.1 resolves local dates and overlap/gap behavior using the Host's IANA data; Schedule does not own offset-sampling logic or add another package layer. Each daily or weekly record stores normalized `HH:mm:ss.SSS` time, its zone, its weekday set where applicable, and a committed next UTC instant. Decoding and restart do not recompute that instant. Valid zone aliases remain readable across canonical-name changes, while subsequent targets use current Host time-zone data. - -Model `schedule_list`, Remote `list`, and the Session header's right-aligned reminder clock show only active tasks. The independent Automation tasks page opens from the sidebar and reads active and inactive Host tasks through the read-only Remote `catalog` method, without activating Sessions or reading their history. That clock is absent while the Session has no active reminder, opens a single task's detail directly when exactly one remains, and otherwise lists them so one can be chosen; both paths open one right-Sidebar task tab that renders the same detail component the Automation tasks page uses, so neither path selects the main panel. The tab names its task and original Session through its navigation parameters and carries that binding into every mutation, so opening or deleting from it never activates a Session. The page has one All, Enabled, and Inactive status filter row, retained inactive details, latest delivery metadata, and an original-Session control that sits in the detail tab strip beside the task actions. Its header's New action starts a new Session, where the reminder is created by asking the model; the page offers no creation form. Navigation waits for Session and archive metadata, explains unavailable targets, and never restores an archived Session implicitly. Task retention remains independent of original-Session availability. Every, Daily, Weekly, and Cron are recurring rules; the page groups tasks only by status. Daily and Weekly rule labels retain the saved local time, exact rule zone, and stored weekday set rather than substituting the browser zone used for next-target display. Explicit confirmed deletion reuses `delete` with the entry's original Session binding, requiring that the supplied binding match the stored task. This argument check is not independent caller authorization: model tools capture the current Agent's Session, while the global user interface selects the task's original binding. Cross-Session model modification policy remains a product decision, not a consequence of the storage layout. A payload-free change notification invalidates queries, and reconnect queries recover missed notifications. Deletion prevents subsequent dispatch but does not retract a queued message. - -The second detail level separates Rules from Delivery records. Opening records requests `history` with the task's original Session binding and an explicit page limit of 1–100. An exclusive message-id cursor reads older receipts without changing position when new deliveries append. Catalogs retain only latest-receipt metadata rather than duplicating full histories in every task-list response. Missing tasks and unknown cursors are distinct failures, not empty successful history. The records view loads lazily, rejects stale responses, and keeps original-Session navigation separate from inspection. - -Name, instruction, and timing updates share the dispatch and deletion FIFO. Comparing the complete observed record inside that queue prevents a stale draft from overwriting a saved rule or a target advanced by delivery. This avoids adding a persisted revision counter, at the cost of rejecting drafts after normal delivery changes the target. One task put replaces the rule, name, or instruction, preserving identity, original Session binding, status, latest receipt, and saved history. A request may omit the timing change, the name, or the instruction; an omitted field keeps its stored value, and a name or instruction change alone leaves the committed target untouched. Normalized no-ops leave the committed target and stored bytes unchanged. Changed one-shots retain their identity as At records; changed fixed intervals use a new accepted-save anchor; changed wall-clock rules re-resolve their first future target from the same accepted-save sample, and an equivalent normalized rule stays a no-op. A timing change may select a different recurrence kind, and the new rule computes its target exactly as creation would from that accepted-save sample. Deletion removes the row together with its saved records ([consequences of that reversal](2026-09-22-schedule-hard-delete-and-bounded-history.md)), so the runtime never schedules the task again and `history` no longer resolves for its original binding. Inactive tasks are not reopened. - -The page uses the existing Host task domain and scheduler, not a mock engine or a Session projection. The name, the instruction, and the run-time rows edit one local draft for the shown task, and one Save commits that draft as a single update carrying the complete expected record; Cancel restores the stored values, a failed save retains the draft, an unsaved draft survives an authoritative refresh, and a committed save and a failed catalog refresh remain separate outcomes. Pause and execution-history operations remain unsupported. Lifecycle status and latest delivery acknowledgment do not describe model results. - The client presentation derives from existing persisted data instead of widening a persisted format. The `schedule_create` transcript card reads the created task out of the call's model-visible result JSON, which already serializes the canonical task value, rather than declaring `presentationMeta`: a persisted metadata channel would add a durable field for a card that Session-log replay reconstructs from the result alone, and previously recorded Sessions render the card without a format change. The Sidebar Session-row clock mark occupies the root-scoped `sidebar.session.row.leading` seat rather than requesting a per-Session provider, because a Session-scoped seat would bind every listed Session and thereby activate and retain it; the same root-scoped owner share feeds `sidebar.session.row.hover`, and both read the shared Host catalog snapshot `schedule/catalog()` without reading a Session log. Session inbox persistence and the task put occur in separate persistence systems. Inbox persistence precedes the task put to avoid marking delivery before its message exists. A crash or task-write failure after inbox persistence can repeat a message; storing status, target, and receipt together does not make the two stores atomic. Dispatch failures retain the task and produce a diagnostic; there is no independent retry timer. This feature does not create or replace a Goal. @@ -56,10 +40,4 @@ Session inbox persistence and the task put occur in separate persistence systems ## Consequences -**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses. - -Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the shipped Web composition mounts the Schedule service for the whole deployment. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported. - -Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim. - -Name, instruction, and timing coverage includes all supported rule kinds, complete-record conflicts, delivery/save ordering, cancellation, normalized no-ops, unchanged-history persistence, pending controls, draft retention, and collision-free task identity. The [built CLI process test](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) drives public Remote requests and reloads actual task-file bytes in a fresh Host. Its task and receipt seeds establish retained-data inputs, not real creation, delivery, or recorded-model evidence; those require ordinary Session operations and captured model requests. +Task discovery and timers do not require loaded Sessions. History remains in the same task row and shares its acknowledgment commit; reads never invent missing prompts, timestamps, or earlier deliveries. Historical Session-log After, At, and Every remain frozen and do not populate this domain. The [Schedule reference](../../../../docs/subsystems/schedule.md) owns timing, management, UI projection, and retention contracts. The [built CLI test](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) checks raw old rows, edit-time normalization, conflicts, and cold restart through public Remote requests; seeded receipts are retained-data evidence rather than delivery evidence. diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md index c5ee3ac455..d88c535087 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md @@ -4,36 +4,20 @@ Status: implemented [English](2026-09-16-host-schedule-storage.md) | 中文 +本 note 仍拥有宿主权威来源、整单元持久性和已保存收件箱回执。[带类型规则存储](2026-10-21-schedule-rule-storage.zh.md)和 [Session 目标](2026-09-17-standalone-schedules.zh.md)替代原管理与目标契约。 + ## Problem 定时消息必须能在重启后被发现,无须加载每个 Session。挂在 live Agent 上的 timer 无法投递目标 Session 仍为 cold 的任务。用户还需要在不激活对话的情况下查看待投递和已投递任务;投递后删除任务会丢失这条管理记录。 ## Decision -现有 Schedule 包拥有一个 Host storage domain,保存每个任务的原始 Session id、提醒记录、`active` 或 `inactive` 状态,最近一次投递回执,以及已保存的投递历史。该 domain 是保留任务的权威来源。Host scheduler 仅为活动任务恢复 timer;到期任务使用 Session controller 现有的解析操作,在原 Session 中排入普通 follow-up。查询和删除任务不会解析 Agent。已存储但缺少状态的记录规范化为 `active`,不扫描或重建历史 Session。 +原有 `schedule` 领域和整单元布局仍是权威来源;v2 接受 v1。[带类型规则存储](2026-10-21-schedule-rule-storage.zh.md)替代原按 Session 所有的管理 API、顶层绑定写入和两状态生命周期;[Session 目标](2026-09-17-standalone-schedules.zh.md)拥有宿主管理范围与执行目标。七种正常时间类型保留已提交发生时点。显式目标优先;缺少目标时读取旧 Session 绑定或预留稳定的首次新建后复用身份。不运行启动迁移。 任务 domain 声明整 unit 布局。任务是权威数据,因此 JSON 后端会把文件不可读、文档损坏、版本不受支持或任务非法作为整批加载失败,而不是解释为删除。恢复失败不会改写 `schedule.json`,初始不存在的文件则作为空 domain 打开;修复该文件后会保留任务身份。`Service.init` 等待存储校验和运行时初始化,管理方法等待同一初始化结果;拥有方卸载时释放迟到的存储资源,而不开始投递。投递调度准入与初始化分开:任务变更提交后、运行时清理开始前,祖先可能关闭 Agent 准入。此类拒绝会记录日志而不自动重试,不会将已持久化的变更变成失败的管理结果。存储非法和清理注册失败仍拒绝启动。 -本决策替代先前的 Session 日志权威来源、仅 live 时激活和只读目录决策。[Schedule 参考](../../../../packages/schedule/schedule/README.zh.md)负责显式绝对时间校验、固定速率计算,以及区分排队接纳与模型完成。历史 `schedule/change` 的创建与 fold 类型通过 `LegacyScheduleRecord` 保持为 After、At 和 Every;独立的宿主解码器接受包含 Daily、Weekly 与 Cron 的当前 `ScheduleRecord`。宿主记录解码器要求已存储的 `title` 去除首尾空白后非空、本身已去除首尾空白且不超过 120 个字符,严格的 task schema 会拒绝整个 domain 的打开,而不是丢弃这样的任务。历史变更解码器容忍缺失的 `title`,以便已写入的 Session 日志仍可读取;该成员存在时,同样按这三项检查校验。storage domain 保持版本 1。历史事件不会填充该 domain,已有 `at` 任务也不会转换为每日或每周规则。加载含有活动旧提醒的 Session 时,会警告需要重新创建提醒。 - -插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后确认持久投递。一次任务记录 put 存储 `lastDelivery`,追加包含发生目标、确认时间、真实消息 id 和指令快照的发送记录,并更新一次性任务的 `inactive` 状态或重复任务的下一目标和状态。因此,任务状态与其保存的历史共用一次提交。未运行任务仍保留在存储中,且不会再次安排投递;显式删除会阻止后续投递,并把该行连同其已保存记录一并移除。这些记录确认收件箱投递,不表示用户收到消息或模型执行完成。 - 新任务初始化空的 `deliveryHistory`。缺少该可选字段的任务会展示已有的最近一次回执(如果存在),并标明更早记录不可用。读取不会重写旧任务,也不会虚构已丢失的历史或指令快照。下一次确认投递时,保留已有回执并追加新记录。严格解码拒绝重复消息 id,以及最近一次回执与历史末项不一致的情况。历史按追加顺序排列,不按墙钟排序,因此时钟回拨不会颠倒已确认投递的顺序。 -重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。两个渲染器输出的固定文本见[投递提醒的来源行](2026-09-24-scheduled-message-framing.zh.md)决策。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。 - -Daily 是互斥的第四个创建选择器,Weekly 是第五个,Cron 是第六个,三者都不是对 `every_seconds` 或 `at` 的转换。Weekly 选择本地日期 ISO 星期属于其所存集合的每个日期;解码器拒绝未规范化的已存集合,选择器拒绝空、重复、超出范围或非整数的集合。它们的首个目标严格晚于当前时间。缺失的本地时间或日期被跳过;重叠时间选择较早时点,每个日期一次。下一目标晚于投递判断时刻,且其本地日期晚于所选发生时点的日期。仅 UTC 目标限制在 0001–9999 年;边缘处的本地日期可以进入第 0 年或第 10000 年。Session 恢复后,运行时依据当前时钟重新核对到期成员,避免时钟回拨时接纳未来任务。 - -受维护的 `@js-temporal/polyfill` 0.5.1 使用宿主 IANA 数据解析本地日期及重叠或缺口;Schedule 不拥有偏移量采样逻辑,也不增加包层级。每日或每周记录保存规范化后的 `HH:mm:ss.SSS` 时间、时区、适用的星期集合及已提交的下一 UTC 时点。解码和重启不会重新计算该时点。规范名称变化后,有效时区别名仍可读取;后续目标使用宿主当前的时区数据。 - -模型 `schedule_list`、Remote `list` 和 Session 顶部右侧的提醒时钟仅显示活动任务。独立的自动化任务页面从侧边栏打开,通过只读 Remote `catalog` 方法读取活动和未运行 Host 任务,不激活 Session 或读取其历史。该时钟在该 Session 没有活动提醒时不显示;恰好有一个时直接打开该任务详情,否则列出提醒供选择。两条路径都打开同一个右侧边栏任务页签,该页签渲染自动化任务页面所用的同一详情组件,因此都不会切换主面板。页签通过导航参数指明任务及其原 Session,并把该绑定带入每个修改操作,因此从页签打开或删除都不会激活 Session。页面提供一行“全部/已开启/未运行”状态筛选,保留未运行任务详情、最近一次投递元数据和固定在位于详情页签条内、与任务操作并排的原 Session 入口。页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面不提供创建表单。导航等待 Session 与归档元数据,解释不可用目标,且不会隐式恢复已归档 Session。任务保留与原 Session 可用性相互独立。Every、Daily、Weekly 和 Cron 均为重复规则;页面只按状态分组。Daily 与 Weekly 规则标签保留保存的本地时间、精确规则时区和所存星期集合,不替换为下一目标显示所用的浏览器时区。显式确认删除复用 `delete`,传入条目的原始 Session 绑定,要求传入绑定与已存任务一致。这项参数校验不是独立的调用者鉴权:模型工具捕获当前 Agent 的 Session,全局用户界面则选择任务的原始绑定。跨会话模型修改政策仍是产品决策,不由存储布局直接决定。无载荷的变更通知使查询失效,重新连接时重新查询以恢复漏掉的通知。删除阻止后续投递,但不撤回已排队消息。 - -第二层详情将“规则”与“发送记录”分开。打开发送记录时,`history` 请求携带任务的原始 Session 绑定,以及 1–100 的显式分页数量。排他的消息 id 游标用于读取更早回执,新投递追加后不会改变其定位。目录仅保留最近一次回执元数据,不在每次任务列表响应中复制完整历史。任务不存在和游标未知属于独立失败,不是查询成功的空历史。记录视图按需加载,拒绝晚到的过期响应,并将原 Session 导航与查看记录分开。 - -名称、指令和时间更新与投递、删除共用 FIFO。在该队列内比较完整的已观察记录,可防止旧草稿覆盖已保存规则或投递已推进的目标。这样无须增加持久版本计数器,代价是正常投递改变目标后也会拒绝旧草稿。一次任务 put 替换规则、名称或指令,保留身份、原始 Session 绑定、状态、最近一次回执和已保存历史。请求可以省略时间变更、名称或指令;省略的字段保留已存储值,仅修改名称或指令不会改变已提交目标。归一化后等价的修改保留已提交目标与存储字节。修改后的一次性任务以 At 记录保留身份;固定间隔改变后以接纳保存时刻重新起算;钟表规则改变后以同一接纳保存时刻重新解析首个未来目标,而归一化后等价的规则不产生变更。时间变更可以选择不同的重复类型,新规则按创建时相同的方式、以同一接纳保存时刻计算目标。删除会移除该行及其已保存记录([该反转的后果](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md)),因此运行时不会再调度该任务,`history` 也不再按其原始绑定解析。未运行任务不会被重新启用。 - -页面使用现有 Host 任务 domain 和调度器,不使用 mock 引擎或 Session projection。名称、指令和运行时间行编辑当前任务的同一份本地草稿,一次保存把该草稿作为携带完整预期记录的单个更新提交;取消恢复已存储值,保存失败保留草稿,未保存的草稿会保留到权威刷新之后,保存已提交与目录刷新失败仍是不同结果。暂停和执行历史操作仍不受支持。生命周期状态与最近一次投递确认不描述模型结果。 - 客户端的呈现从已有持久化数据派生,而不扩展持久化格式。`schedule_create` transcript(文本记录)卡片从该调用对模型可见的结果 JSON 中读出创建的任务——该 JSON 已经序列化规范任务值——而不是声明 `presentationMeta`:持久化 metadata 通道会为一张仅凭结果即可在 Session 日志回放中重建的卡片增加持久字段,而早于该卡片录制的 Session 无需格式变更即可渲染它。侧栏 Session 行的时钟标记占用 root 作用域的 `sidebar.session.row.leading` slot,而不是请求逐 Session provider,因为 Session 作用域的 slot 会绑定列表中每个 Session,从而激活并保留它;同一个 root 作用域属主约定也供给 `sidebar.session.row.hover`,两者都读取共享的 Host 目录快照 `schedule/catalog()`,不读取 Session 日志。 Session 收件箱持久化和任务 put 发生在两个持久化系统中。收件箱持久化先于任务 put,以免消息尚不存在时就标记已投递。收件箱持久化后崩溃或任务写入失败可能重复发送消息;状态、目标和回执一起存储,并不使两个存储具有原子性。投递失败保留任务并输出诊断;没有独立的重试 timer。此功能不创建或替换 Goal。 @@ -56,10 +40,4 @@ Session 收件箱持久化和任务 put 发生在两个持久化系统中。收 ## Consequences -**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。 - -任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,而随发行版交付的 Web 组合为整个部署挂载 Schedule 服务。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。 - -必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。 - -名称、指令和时间编辑覆盖范围包括所有支持的规则类型、完整记录冲突、投递与保存的顺序、取消、规范化后的无变更、历史持久化不变、等待期间的控件、草稿保留,以及无碰撞的任务标识。[已构建 CLI 进程测试](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) 驱动公开 Remote 请求,并在新 Host 中重新加载实际任务文件字节。任务和回执种子只是已保留数据的输入,不是真实创建、投递或模型录制证据;这些证据需要普通 Session 操作与捕获的模型请求。 +任务发现和定时器无需已加载 Session。历史保留在同一任务行中,与确认一同提交;读取不虚构缺失指令、时间戳或更早投递。历史 Session 日志 After、At、Every 保持冻结,不填充此领域。[Schedule 参考](../../../../docs/subsystems/schedule.zh.md)拥有时间、管理、UI 投影和保留契约。[构建后 CLI 测试](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts)通过公开 Remote 请求检查旧原始行、编辑时规范化、冲突和冷重启;种入回执是保留数据证据,不是投递证据。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.i18n.yaml new file mode 100644 index 0000000000..eca5b805b7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-17-standalone-schedules.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-standalone-schedules.md +/agent-note-standalone-schedules-and-session-targets: + en: ce8ed282ec843da0 + zh: a5ca78422ea009ec +/agent-note-standalone-schedules-and-session-targets/problem: + en: 2a6769cd378455bb + zh: 84cfb0f2e1fde8e1 +/agent-note-standalone-schedules-and-session-targets/decision: + en: 73ed15d2219f2898 + zh: db8ea6ae79a95241 +/agent-note-standalone-schedules-and-session-targets/alternatives-considered: + en: 1def5c19511fc59e + zh: 84f0f5d568272c39 +/agent-note-standalone-schedules-and-session-targets/consequences: + en: bda079e488b5374b + zh: d5ffe96b33e8e65d diff --git a/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.md b/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.md new file mode 100644 index 0000000000..e22bbc4628 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.md @@ -0,0 +1,35 @@ +# Agent Note: Standalone schedules and Session targets + +Status: implemented + +English | [中文](2026-09-17-standalone-schedules.zh.md) + +## Problem + +Host tasks need global management and explicit delivery targets while remaining recognizable in their Workspace. + +## Decision + +The storage domain, importer, placeholder RRULE, and parallel API decisions are superseded by [typed rule storage and shared recurrence](2026-10-21-schedule-rule-storage.md). Keep the existing `schedule` domain and one management API; the target, execution, and notice decisions below remain current. + +Targets are `session`, `new_once`, or `new`. `session` stores one existing Session id; create and edit check, without activating that Session, that it exists and does not belong to a delegated child. `new_once` reserves a Session id when the task is created or retargeted, creates that exact Session at first delivery, and then becomes `session`. `new` saves a reserved Session id in `pendingDelivery` before each create attempt, so a retry after a failed delivery uses the same Session. A created Session uses the target's working directory and Agent preset, or the Host defaults. When the target names a working directory, the Session joins the Workspace registered there, if any. While the Session has no title, it takes the task title; a `new` target adds the Session's creation minute in the timing rule's zone, for example `Weekday digest · 2026-10-12 09:00`, so the Sessions of successive deliveries stay distinguishable. A failed join or rename is logged and does not block delivery. `involvedSessionIds` lists the creating and editing Agent Sessions and each existing or reserved target Session in first-involvement order; Sessions that a `new` target creates are not listed. + +The runtime keeps latest-only catch-up. It delivers the latest occurrence at or before the delivery decision and advances to the next future occurrence; an exhausted rule stays stored with `ended` status, and a paused task has no timer. Each delivery of one task, timed or manual, comes at least 60 seconds after the occurrence of the previous delivery, and the occurrence of a manual run is its run time; for a `new` target, whose every delivery creates a Session, a deployment can raise that interval through `newSessionIntervalSeconds`, which defaults to 60. The floor counts from the previous occurrence when the next stored occurrence keeps the interval, and from the previous acknowledgment otherwise, so acknowledgment latency does not accumulate and a late delivery does not shift later occurrences; the next on-time occurrence can then follow the late delivery by less than one interval. A task delivered to a reused Session, `session` or `new_once`, holds its execution slot until the target Agent is idle, and occurrences that become due meanwhile coalesce into one later delivery, so that Session has at most one pending delivery of the task; a `new` target holds no slot, and its launches run beside one another with only the delivery interval between them. Due tasks with a `session` target share one message per target Session, except After and At tasks. Manual run shares target resolution, the delivery interval, Session inbox persistence, the receipt, and the involvement update with timed delivery, but keeps the next occurrence and status. Both refuse an archived destination Session, including a reserved retry, because the archived-Session gate would block the turn that the message wakes. Session inbox persistence precedes the task receipt write, so a crash between the two stores can repeat a message. The implementation adds no scheduler, run, or ownership service. + +After a committed create, update, or delete, Schedule queues a change notice on its own chain, so Session I/O never holds the mutation and delivery queue. Each involved Session other than the actor whose Agent is loaded receives a `schedule`-sourced message for its next model step that does not start a model turn, so notices queued while the Session is idle reach the model with its next input instead of each running as a model turn; the message names the change and carries the task id and title as JSON strings. A Session without a loaded Agent receives nothing and reads the current task when it next lists tasks. A notice failure is logged and does not roll back the committed change. Shutdown drains queued notices before storage closes. + +## Alternatives considered + +**Require a caller-supplied schedule id.** A user-facing title and canonical identity serve different purposes, but accepting ids would expose collision and validation policy. The Host generates identity and returns it for later operations. + +**Keep creator Session ownership.** Ownership would keep list and mutation APIs scoped and would conflict with global management, retargeting, and Session creation policies. Historical involvement is metadata and notification routing, not authorization. + +**Check spacing by sampling upcoming occurrences.** Each occurrence query enumerates from `DTSTART`, so a high-frequency rule with an early start blocked the Host thread before a sampled check could refuse it, and a sample cannot bound the complete sequence. Static rule checks before enumeration and the per-task delivery interval enforce the spacing instead. + +**Add scheduler, run, and notification services.** The existing Schedule service, storage domain, runtime, and Session controller already own these responsibilities at the required scale. New layers would add indirection without changing durability. + +**Leave created Sessions untitled and outside Workspaces.** Such a Session appears as one more untitled Session outside the Workspace that owns its directory, so the user cannot tell which task created it or, for a `new` target, which delivery it received. Joining the Workspace at the target directory and naming the Session after the task, with the creation minute for `new`, needs no stored field. A failure is only logged because delivery has priority over presentation. + +## Consequences + +Tasks outlive Agents and are managed by id. Archive admission reports active Session targets; explicit stop deletes tasks. Host availability, cross-store at-least-once delivery, best-effort notices, and unbounded per-delivery concurrency remain limitations. The [Schedule reference](../../../../docs/subsystems/schedule.md) owns the exact current contracts and the [management upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/schedule-remote-host-tasks/guide.md) covers changed callers. diff --git a/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.zh.md b/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.zh.md new file mode 100644 index 0000000000..8ce6ff8cae --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-standalone-schedules.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 独立计划任务与 Session 目标 + +Status: implemented + +[English](2026-09-17-standalone-schedules.md) | 中文 + +## Problem + +宿主任务需要全局管理和显式执行目标,并在工作区中可辨认。 + +## Decision + +存储领域、导入器、占位 RRULE 和并行 API 决策由[带类型规则存储与共享重复计算](2026-10-21-schedule-rule-storage.zh.md)替代。保留现有 `schedule` 领域和统一管理 API;下述目标、执行和通知决策保持有效。 + +目标分为 `session`、`new_once` 和 `new`。`session` 存储一个现有 Session id;创建和编辑会在不激活该 Session 的情况下检查它存在且不属于被委派的子 Agent。`new_once` 在创建任务或重新指定目标时预留 Session id,在首次投递时创建该精确 Session,然后转换为 `session`。`new` 在每次创建尝试前把预留的 Session id 保存在 `pendingDelivery` 中,因此投递失败后的重试使用同一 Session。新建 Session 使用目标中的工作目录和 Agent preset,未提供时使用 Host 默认值。目标指定工作目录时,该 Session 加入在该目录注册的 Workspace(若存在)。该 Session 尚无标题时以任务标题命名;`new` 目标再加上该 Session 的创建时间(精确到分钟,按规则 `DTSTART` 的时区),例如 `Weekday digest · 2026-10-12 09:00`,以便区分各次投递创建的 Session。加入或命名失败只记录日志,不阻止投递。`involvedSessionIds` 按首次参与顺序列出创建和编辑任务的 Agent Session,以及每个现有或预留的目标 Session;`new` 目标创建的 Session 不列入其中。 + +运行时保留仅补发最新发生时点的行为。它投递不晚于投递判断时刻的最新发生时点,并推进到下一个未来发生时点;耗尽的规则以 `ended` 状态保留在存储中,暂停的任务没有计时器。同一任务的每次投递,无论定时还是立即执行,都在上一次投递的发生时点之后至少 60 秒,立即执行的发生时点是其运行时间;`new` 目标的每次投递都会创建 Session,部署可通过 `newSessionIntervalSeconds` 加大该间隔,默认 60。下一个已存储发生时点满足该间隔时,最小投递间隔从上一次发生时点起算,否则从上一次确认时间起算,因此确认延迟不会累积,延迟的投递也不会推移之后的发生时点;下一个准时的发生时点随后可能在延迟投递之后不足一个间隔到来。投递到复用 Session(`session` 或 `new_once`)的任务在目标 Agent 空闲之前一直占用其执行槽位,期间到期的发生时点合并为之后的一次投递,因此该 Session 最多有该任务的一次待处理投递;`new` 目标不占用槽位,各次启动并行运行,彼此之间只隔投递间隔。目标为 `session` 的到期任务按目标 Session 共享一条消息,After 和 At 任务除外。立即执行与定时投递共用目标解析、投递间隔、Session 收件箱持久化、回执与相关 Session 更新,但保留下一发生时点和状态。二者都拒绝已归档的目的 Session,包括重试时复用的预留 Session,因为已归档会话门禁会阻止该消息唤醒的轮次。Session 收件箱持久化先于任务回执写入,因此两个存储之间崩溃可能重复消息。实现不增加 scheduler、run 或 ownership service。 + +create、update 或 delete 提交后,Schedule 在独立的链上排队一条变更通知,因此 Session I/O 不会占用变更与投递队列。除操作者之外、Agent 已加载的每个相关 Session 都会收到一条供下一个模型步骤读取的 `schedule` 来源消息,该消息不会启动模型轮次,因此 Session 空闲期间排队的通知会随其下一条输入到达模型,而不会各自作为一个模型轮次运行;消息说明变更类型,并以 JSON 字符串携带任务 id 和标题。没有已加载 Agent 的 Session 不会收到消息,会在下次列出任务时读取当前任务。通知失败会记录日志,但不会回滚已提交的变更。关闭时先排空已排队的通知,再关闭存储。 + +## Alternatives considered + +**要求调用方提供计划任务 id。** 用户可见标题与规范身份用途不同,但接受 id 会暴露冲突与校验策略。Host 生成身份并返回,供后续操作使用。 + +**保留创建者 Session 所有权。** 所有权会让 list 与 mutation API 保持作用域限制,并与全局管理、重新指定目标和 Session 创建策略冲突。历史参与关系是元数据和通知路由,不是授权。 + +**通过抽样即将到来的发生时点检查间隔。** 每次发生时点查询都从 `DTSTART` 开始枚举,因此起始时间较早的高频规则会在抽样检查拒绝它之前阻塞 Host 线程,而且抽样无法约束完整序列。改为在枚举之前做静态规则检查,并用每个任务的投递间隔约束间隔。 + +**增加 scheduler、run 和 notification service。** 现有 Schedule service、存储领域、runtime 和 Session 控制器已按所需规模承担这些职责。新层级只会增加间接性,不改变持久性。 + +**让新建 Session 保持无标题且不属于任何 Workspace。** 这样的 Session 会作为又一个无标题 Session 出现在拥有其目录的 Workspace 之外,用户无法分辨是哪个任务创建了它,也无法分辨对于 `new` 目标它接收了哪一次投递。加入目标目录处的 Workspace,并以任务标题命名 Session(`new` 再加创建分钟),不需要存储字段。失败只记录日志,因为投递优先于呈现。 + +## Consequences + +任务寿命长于 Agent,并按 id 管理。归档准入报告活动 Session 目标;显式停止删除任务。宿主可用性、跨存储至少一次投递、尽力通知和每次新建目标的无界并发仍是限制。[Schedule 参考](../../../../docs/subsystems/schedule.zh.md)拥有精确的当前契约,[管理升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/schedule-remote-host-tasks/guide.zh.md)说明调用方变更。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml index 0fb431067d..10be2a7053 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml @@ -9,8 +9,8 @@ en: 2abacff687661d28 zh: 2d7ba3ae27dba888 /agent-note-keep-live-configuration-in-the-owning-profile/decision: - en: 66c3884425cfb90a - zh: 0fe6a1f1a576470b + en: f9b1dd3d158457a9 + zh: 06ca76fcbbc76cda /agent-note-keep-live-configuration-in-the-owning-profile/alternatives-considered: en: 4089464ad1fb60bd zh: 3168e9e4a205145d diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md index 65cc8f60b1..aabe6f5c43 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md @@ -14,7 +14,7 @@ A plugin declares every configurable value in its Cordis Config. Fields that can Form writes target the active profile. Home patches and command-line overlays remain higher-priority deployment inputs; an edit they would shadow fails before persistence. Two plugin instances are addressed by their distinct profile entry ids. Nested Includes retain independent ownership and are not writable through the profile form. -Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a Web Search edit, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide. +Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a legacy Messages search edit on the Web search and fetch page, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide. The removed `$DSH_HOME/settings.yaml` is imported once into the active profile when the Loader has settled every entry after Settings starts: section ids are entry ids, with `ui-developer-tools` → `ui-settings`, `ui-onboarding` → `ui-settings-general`, and `shell` → the platform's shell executor entry. The file is renamed to `settings.yaml.imported` before the first write, so the import never repeats; a rejected section is logged and stays in the renamed file. diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md index 9ce0952aab..f808941c35 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md @@ -14,7 +14,7 @@ Status: implemented 表单写入当前 profile。home patch 和命令行 overlay 仍是优先级更高的部署输入;如果它们会遮蔽某次修改,编辑器会在持久化前拒绝写入。两个插件实例通过各自不同的 profile entry id 寻址。嵌套 Include 保留独立所有权,不能通过该 profile 表单写入。 -Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,编辑网页搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。 +Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,在「网页搜索与获取」页编辑旧版 Messages 搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。 被移除的 `$DSH_HOME/settings.yaml` 会在 Settings 启动后、Loader 完成所有条目加载时一次性导入当前 profile:section id 即 entry id,其中 `ui-developer-tools` → `ui-settings`、`ui-onboarding` → `ui-settings-general`、`shell` → 当前平台的 shell 执行器条目。文件在第一次写入前改名为 `settings.yaml.imported`,因此导入不会重复;被拒绝的 section 会记录日志并保留在改名后的文件中。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml index c0587791fd..e4acbc9423 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.i18n.yaml @@ -9,11 +9,11 @@ en: 97fe58a13135b4a1 zh: 7490373a8d941da5 /agent-note-active-tool-headers-and-stateful-request-projection/decision: - en: 0c40a9283650c643 - zh: 91a18d752d09a9ce + en: 24138152d511bf12 + zh: 95516d7566b6db7c /agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered: - en: b78785f2a603eba8 - zh: b8c268228da7e2e7 + en: 311db1f781e386e7 + zh: 7e3312544154a158 /agent-note-active-tool-headers-and-stateful-request-projection/consequences: - en: 60d7bf28fa5a8fd1 - zh: a17b222e7aca01e2 + en: 8a64dba42c01f647 + zh: 9e7ba7a5bd855031 diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md index a40b85601c..8a8976aacb 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.md @@ -14,9 +14,9 @@ Plugin and MCP tools change during conversations. Providers differ in whether th `Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration. -Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. Only current-series update identities survive. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. Explicit deferred loading remains available on capable routes. +Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. On addition-only routes, removing any tool rebuilds the route's baseline after that update: the request declarations change even when the removed tool was activated later, so retaining superseded activation blocks cannot preserve the full conversation prefix. Adding tools when no declarations exist also rebuilds the baseline. Deferred declarations count as existing tools. The first native tool changes the empty declaration prefix; deferring it provides no cache advantage over declaring it immediately. Previously activated tools become immediate; explicitly deferred declarations with no activation remain deferred. Subsequent additions remain deferred until their recorded activation. Superseded tool blocks are omitted without discarding other content in those messages. The Session fold and durable events remain capability-independent. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. -DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. +DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai selects `in-history` for compatible Anthropic routes and `addition-only` for compatible Chat Completions and Responses routes. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists. ## Alternatives considered @@ -26,6 +26,8 @@ DeepSeek translates projected updates into system-role `tool_addition` and `tool **Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers. +**Rebuild the baseline on every removal.** Deferred tools also have request declarations; deleting a tool changes those declarations regardless of its activation position. Addition-only routes cannot express that deletion in history. The rebuild therefore also covers removal-only updates and removals of later-activated tools. A past reset cannot force every future addition to become immediate: each reset establishes a new baseline from which deferred additions resume. + **Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes. ## Consequences @@ -34,4 +36,6 @@ Tool lists and logical update events are capability-independent; only outgoing d The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations. +The [DeepSeek replacement scenario](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml) checks the first additions to an empty set, a rebuilt baseline, a subsequent deferred addition, and removal of that later-activated tool through Messages serialization. Explicit deferred-only requests remain unchanged under the [DeepSeek failure policy](../simplification/2026-11-02-deepseek-deferred-tool-failures.md). These checks establish request construction, not measured cache savings from rebuilding. + The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries. diff --git a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md index 372dcb27af..e9f48c8a02 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-dynamic-tool-updates.zh.md @@ -14,9 +14,9 @@ Status: implemented `Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。 -循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。仅保留当前序列的更新标识。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。显式延迟加载在支持的路由上仍然可用。 +循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。在 addition-only 路由移除任何工具时,使用该次更新后的状态重建路由基线:即使被移除工具是后来激活的,请求声明也已发生变化,保留已被取代的激活块无法保留完整对话前缀。不存在任何声明时添加工具,也会重建基线。延迟声明也计入已有工具。首个原生工具会改变空声明前缀;将其延迟加载并不比直接声明具有缓存优势。此前已激活工具变为立即加载;没有激活记录的显式延迟声明仍保持延迟。后续添加在记录的激活位置前保持延迟加载。已被取代的工具块会被省略,但不会丢弃这些消息中的其他内容。Session 归约和持久事件仍不依赖模型能力。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。 -DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 +DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 为兼容的 Anthropic 路由选择 `in-history`,为兼容的 Chat Completions 和 Responses 路由选择 `addition-only`。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。 ## Alternatives considered @@ -26,6 +26,8 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to **每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。 +**每次移除都重建基线。** 延迟工具也具有请求声明;删除工具会改变这些声明,与其激活位置无关。addition-only 路由无法通过历史表达删除。因此,仅移除的更新和移除后来激活的工具也会重建基线。一次历史重置不能让所有后续添加都变为立即加载:每次重置确立新基线,后续添加继续采用延迟加载。 + **在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。 ## Consequences @@ -34,4 +36,6 @@ DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `to 手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。 +[DeepSeek 替换场景](../../../../snapshots/session/deepseek-deferred-tools/snapshot.yml)通过 Messages 序列化验证向空工具集首次添加工具、重建后的基线、后续延迟添加,以及移除该后来激活的工具。根据 [DeepSeek 失败策略](../simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md),显式全延迟请求保持原样。这些检查验证请求构造,不代表已经测量重建带来的缓存收益。 + [真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml index bbac97a7ad..09d97dbe74 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -36,8 +36,8 @@ en: 21a74f9031f86a92 zh: c6abd0a5dfbf56a8 /agent-note-definition-owned-grouping-with-generic-node-group-assembly/verification: - en: b42409d029fc0fde - zh: 0f8ab7d206cab772 + en: ff829b9a2772df95 + zh: df0e6524d00034df /agent-note-definition-owned-grouping-with-generic-node-group-assembly/consequences: - en: 96f1d81b28534661 - zh: 8cb1c7a0f000c335 + en: 236ddec90acc6ca1 + zh: d4807f635e217766 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index 39a31ae902..7a41965408 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -159,7 +159,7 @@ Business behavior is verified through keyed-update regressions and recorded Web | Evidence | Required observation | |---|---| | Keyed notifications and React updates | Content growth affects the owning Node and affected Group; unchanged historical rows and unrelated Turns receive no extra notifications. Mode changes preserve keys and member parents. | -| Recorded Web replay | Current accessible titles, group disclosure, hidden ordinary Context, trigger notices, and footer placement match the committed expected output. | +| Recorded Web replay | Current accessible titles, group disclosure, Context rows, trigger notices, and footer placement match the committed expected output. | [Recorded Web scenarios](../../../../apps/web/tests/steering.e2e.ts) cover online steering, reconnect handoff, and grouped presentation through the shipped Web profile. These behavior checks do not establish a quantified latency or memory improvement. @@ -171,4 +171,4 @@ Business behavior is verified through keyed-update regressions and recorded Web - Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made. - Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes. - Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction belong to Chat's business adaptation; generic reference tests alone do not establish those behaviors. -- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Hiding ordinary Context retains non-human waking notices and original Session/Trajectory inspection; footer placement depends on the real Turn end rather than group membership. +- Turn status/duration replaces the old tool/message-count title, so status, accessible names, and empty-process Turns require explicit presentation coverage. Non-human waking notices remain independent of Context rows, and Session/Trajectory inspection is unchanged; footer placement depends on the real Turn end rather than group membership. diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index b469ad12ca..288b8b5234 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -159,7 +159,7 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 | 证据 | 必须观察到的结果 | |---|---| | 按键通知与 React 更新 | 正文增长影响所属 Node 和相关 Group;未变化历史行及其他 Turn 没有额外通知。模式变化保留 key 与成员父级。 | -| 已录制的 Web 回放 | 当前可访问标题、组开合、普通 Context 隐藏、触发通知及页脚位置与提交的预期输出一致。 | +| 已录制的 Web 回放 | 当前可访问标题、组开合、Context 行、触发通知及页脚位置与提交的预期输出一致。 | [已录制的 Web 场景](../../../../apps/web/tests/steering.e2e.ts)通过正式 Web profile 覆盖在线 steering、重连接续与分组展示。这些行为检查不构成量化延迟或内存改善的证明。 @@ -171,4 +171,4 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 - 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 - 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 - 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动属于 Chat 业务适配,仅凭通用引用测试不能确认这些行为。 -- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。隐藏普通 Context 时保留非人工唤醒通知及原始 Session/Trajectory 查看能力;页脚定位依赖真实 Turn 结束,不依赖分组成员。 +- 整轮状态与用时替代旧工具/消息计数标题,因此状态、可访问名称和空过程轮次需要明确的展示覆盖。非人工唤醒通知独立于 Context 行,Session/Trajectory 查看能力不变;页脚定位依赖真实 Turn 结束,不依赖分组成员。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml index 575b3ca146..cf62bcbb22 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml @@ -6,14 +6,14 @@ en: 17a73230ad22ec0b zh: 5bcf27d7c0e86764 /agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/problem: - en: 98e82a0add324882 - zh: c10e85a3f2a522f8 + en: c49b11c393e59f35 + zh: 03cab3ae79a6c878 /agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/decision: - en: 56328745115714b5 - zh: f30d39f59aa06a13 + en: 48272c9bbe2a0040 + zh: 668799310bcd20ba /agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/alternatives-considered: - en: 0c4eb01054be1141 - zh: 95980f8097fa36fd + en: 7663bb52e1034eeb + zh: 2ef8f146c5642837 /agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/consequences: - en: 05121f4b77b8ee12 - zh: 1751c9b1a752ce6b + en: f911ddff2a9b81da + zh: 38ee18e3f6b10c26 diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md index 90127d6887..691e9e6318 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md @@ -6,15 +6,15 @@ English | [中文](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md) ## Problem -The Schedule domain is one whole-unit JSON document that the backend rewrites on every mutation, so every retained byte in a task row grows every write. Saved delivery records were unbounded: each acknowledgment appended to the array, and a deleted task kept its row and records so the open detail could still read them. The document's size therefore followed a task's delivery count, and deletion added a state every reader had to skip. +The Schedule domain stores each task as one JSON document that the backend rewrites whole on every change of that task, so every retained byte in a task row grows every write of that task. Saved delivery records were unbounded: each acknowledgment appended to the array, and a deleted task kept its row and records so the open detail could still read them. The document's size therefore followed a task's delivery count, and deletion added a state every reader had to skip. ## Decision -Deletion is hard deletion. `ScheduleService.delete` removes the storage row through `tasks.delete`, so the task stops scheduling, leaves `list` and `catalog`, and `history` answers `schedule_not_found` for the same `(sessionId, id)`; the row's saved delivery records are removed with it. The task schema has no `deleted` field, and no client surface offers a recycle bin or a read of an already deleted task's records. +Deletion is hard deletion. The `delete` action of `ScheduleService.edit` removes the storage row through `tasks.delete`, so the task stops scheduling, leaves `list` and `catalog`, and `history` answers `schedule_not_found` for its id; the row's saved delivery records are removed with it. `deleteInactive` removes only ended tasks in one `tasks.deleteMany` batch and preserves active and paused tasks. The task schema has no `deleted` field, and no client surface offers a recycle bin or a read of an already deleted task's records. Delivery history is bounded by plugin `Config`: `deliveryHistoryDays` (default 30, 1–3650) and `deliveryHistoryRecords` (default 200, 1–10000). `appendDelivery` applies both bounds on the only write path. It keeps the records whose `deliveredAt` is at or after the appended receipt's `deliveredAt` minus the configured day window, keeps the newest `deliveryHistoryRecords` of those, and always retains the appended latest receipt; a pruned window sets `earlierRecordsUnavailable` and `earlierRecordsPruned`, which later appends preserve. The latter flag is optional in stored rows and confirms actual removal; legacy unavailability alone does not imply pruning. The history response includes the current retention limits, and the Client shows the cleanup notice only for confirmed pruning after loading the final nonempty page. -The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision: one authoritative version-1 task domain, timer restoration for active tasks only, cold-Session delivery through the Session controller, the catalog and detail surfaces, and the historical-versus-Host decoder split. Its status vocabulary is `active` or `inactive`; `inactive` is the sole non-active name, and the client filter row reads All, Enabled, and Inactive. That note stays active: this supersession is partial, so it is neither archived nor frozen, and it remains the owner of everything above while the deletion and retention decisions it recorded are replaced here. +The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision: timer restoration for active tasks only, cold-Session delivery through the Session controller, the catalog and detail surfaces, and the historical-versus-Host decoder split. That note stays active: this supersession is partial, so it is neither archived nor frozen, and it remains the owner of everything above while the deletion and retention decisions it recorded are replaced here. [Standalone schedules and Session targets](2026-09-17-standalone-schedules.md) owns the authoritative task domain and its `active`, `paused`, or `ended` status. Native and Remote catalogs report these same three statuses across the Host. The Automation tasks page's status filter row reads All, Enabled, Paused, and Ended. ## Alternatives considered @@ -24,10 +24,10 @@ The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note ke **Measure the window from the task instead of from each receipt.** Anchoring the window to the task's creation or committed target would discard a newly written receipt of an old task. Measuring back from the appended receipt's `deliveredAt` keeps the window anchored to the data being kept. -**Keep history unbounded and let operators clean up.** The domain publishes one whole-unit document per write, so unbounded history makes the storage document's size a function of delivery counts. Retention is a bound the storage layout requires, not a retention preference. +**Keep history unbounded and let operators clean up.** The backend rewrites each task document whole on every write of that task, so unbounded history makes the task document's size a function of delivery counts. Retention is a bound the storage layout requires, not a retention preference. ## Consequences Deletion now offers no recovery: a deleted task's saved deliveries cannot be read back, and a task that still needs its full history must be read before deletion. Configured retention also discards receipts the task once had; the retained flag records that a window was pruned rather than pretending the history is complete. -In exchange, the persisted task set has one deletion semantics, the storage document's size is bounded by configuration instead of by delivery counts, and `inactive` is the only non-active lifecycle name. The plugin `Config` is the place to raise the bounds when a deployment needs longer delivery history. +In exchange, the persisted task set has one deletion semantics, the size of each task document is bounded by configuration instead of by delivery counts, and deletion adds no lifecycle state. The plugin `Config` is the place to raise the bounds when a deployment needs longer delivery history. diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md index dfc8ab4ff3..67552c5a41 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md @@ -6,15 +6,15 @@ Status: implemented ## Problem -Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档,因此任务行中保留的每个字节都会随每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。 +Schedule domain 把每个任务存储为一个 JSON 文档,后端在该任务每次变更时整体重写它,因此任务行中保留的每个字节都会随该任务的每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。 ## Decision -删除是硬删除。`ScheduleService.delete` 通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。任务 schema 没有 `deleted` 字段,也没有任何界面提供回收站或读取已删除任务记录的能力。 +删除是硬删除。`ScheduleService.edit` 的 `delete` 操作通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对其 id 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。`deleteInactive` 通过一次 `tasks.deleteMany` 批量写入仅删除已结束任务,保留活动和暂停任务。任务 schema 没有 `deleted` 字段,也没有任何界面提供回收站或读取已删除任务记录的能力。 投递历史受插件 `Config` 限制:`deliveryHistoryDays`(默认 30,1–3650)与 `deliveryHistoryRecords`(默认 200,1–10000)。`appendDelivery` 在唯一的写入路径上同时应用这两个上限。它保留 `deliveredAt` 不早于「本次追加回执的 `deliveredAt` 减去配置的天数窗口」的记录,在其中保留最新的 `deliveryHistoryRecords` 条,并始终保留本次追加的最近一次回执;发生裁剪时设置 `earlierRecordsUnavailable` 和 `earlierRecordsPruned`,后续追加会保留这两个标志。后者在存储行中可选,用于确认实际删除;仅有旧格式的不可用标志不表示发生过裁剪。历史响应携带当前保留上限,客户端仅在加载完最后一页且历史非空、确认裁剪时显示清理提示。 -[Host-owned scheduled messages](2026-09-16-host-schedule-storage.zh.md) 笔记保留其余决策:唯一的权威版本 1 任务 domain、只为活动任务恢复定时器、通过 Session controller 投递到冷会话、目录与详情界面,以及历史解码器与宿主解码器的划分。其状态词汇为 `active` 或 `inactive`;`inactive` 是唯一的非活动名称,客户端筛选行为全部、活动和未运行。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。 +[Host-owned scheduled messages](2026-09-16-host-schedule-storage.zh.md) 笔记保留其余决策:只为活动任务恢复定时器、通过 Session controller 投递到冷会话、目录与详情界面,以及历史解码器与宿主解码器的划分。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。[独立计划任务与 Session 目标](2026-09-17-standalone-schedules.zh.md) 拥有权威任务 domain 及其 `active`、`paused` 或 `ended` 状态。原生和 Remote 目录在宿主范围内报告相同的三种状态。自动化任务页面的状态筛选行为全部、已开启、已暂停和已结束。 ## Alternatives considered @@ -24,10 +24,10 @@ Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档 **以任务而非每条回执为窗口起点。** 把窗口锚定在任务的创建时间或已提交目标上,会丢弃旧任务刚写入的回执。从本次追加回执的 `deliveredAt` 向前计算,使窗口锚定在所要保留的数据上。 -**保留无上限的历史,由运维清理。** 该 domain 每次写入都发布整个 unit 文档,因此无上限的历史会让存储文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。 +**保留无上限的历史,由运维清理。** 后端在该任务每次写入时整体重写其任务文档,因此无上限的历史会让任务文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。 ## Consequences 删除不再提供恢复能力:已删除任务的已保存投递无法再读回,仍需要完整历史的必须在删除前读取。配置的保留策略也会丢弃任务曾经拥有的回执;保留的标志记录窗口发生过裁剪,而不是假装历史完整。 -换取的是:持久任务集合只有一种删除语义,存储文档大小由配置而非投递次数决定,且 `inactive` 是唯一的非活动生命周期名称。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。 +换取的是:持久任务集合只有一种删除语义,每个任务文档的大小由配置而非投递次数决定,且删除不增加生命周期状态。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml index 9ed59785ea..ee3f5d9048 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.i18n.yaml @@ -9,8 +9,8 @@ en: e42db40b879fdbf4 zh: 6017da9c4da17835 /agent-note-node-ptc-execution-modes-and-in-memory-state/decision: - en: 7b5808a31493ac1e - zh: 8d9c698c41dbf18e + en: 411054071757d5fe + zh: e683ef7d80f33fc6 /agent-note-node-ptc-execution-modes-and-in-memory-state/alternatives-considered: en: 396157a9fc293198 zh: a061f05b4423e26f diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md index fac34c9f7f..c311f397dc 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.md @@ -18,7 +18,7 @@ One npm package and one plugin entry keep existing installation and loading path The Session records submitted programs, nested tool calls and returned output. It does not serialize the REPL heap or rerun earlier programs after restart, resume or fork: replaying automation could repeat external side effects. A missing variable is a normal program error, and the model-visible instructions require recreating variables and reacquiring resources. No environment identifier or new durable Session event is required because the provider exposes no unlogged model input. -Cancellation, deadline expiry, protocol or output-limit failure, owner disposal and process exit discard the environment. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. +Protocol or output-limit failure, owner disposal and process exit discard the environment. The [interruption decision](2026-10-29-retained-ptc-interruption.md) owns timeout and cancellation recovery with retained state. Ordinary exceptions retain earlier mutations. A retained function uses the active evaluation's host bindings; asynchronous work from an earlier evaluation cannot acquire a later evaluation's tool authority or output sink. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md index 93959fe07a..f91dfff11b 100644 --- a/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-23-best-effort-persistent-ptc.zh.md @@ -18,7 +18,7 @@ Status: implemented Session 记录提交的程序、嵌套工具调用和返回输出。它不序列化 REPL 堆,也不在重启、恢复或派生后重新执行旧程序,因为重放自动化可能重复外部操作。缺失变量产生普通程序错误,模型指引要求重新创建变量并获取资源。提供方没有引入日志之外的模型输入,因此不需要环境标识或新的持久 Session 事件。 -取消、截止时间到期、协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。 +协议失败、输出超限、所有者销毁和进程退出都会丢弃环境。[中断决策](2026-10-29-retained-ptc-interruption.zh.md)负责保留状态的超时和取消恢复。普通异常保留此前发生的修改。保留的函数使用当前执行的宿主绑定;较早执行中的异步任务不能获取后续执行的工具权限或向其输出。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml index 01a5ad7732..9a557c0044 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.i18n.yaml @@ -9,14 +9,14 @@ en: 27b4d1d25cd1c178 zh: 3ddc4d9a09b6182d /agent-note-preset-scoped-schedule-tools/decision: - en: 3c4708981d34217d - zh: cd0c07dc0888f3db + en: 652fb37ad4e5a826 + zh: f5c3cc1e9a79b4ca /agent-note-preset-scoped-schedule-tools/alternatives-considered: en: 5cfe619038339caa zh: c2d2651d304d867c /agent-note-preset-scoped-schedule-tools/consequences: - en: d7c7e6b8e6268559 - zh: 801e5be70e00cc90 + en: aceed1aa98ed9bec + zh: b9d99a25a8b25417 /agent-note-preset-scoped-schedule-tools/testing: - en: d42cb866907a96b5 - zh: 6e66f8babe744bcb + en: 05080859ac790524 + zh: 4b220c10da961608 diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md index 169f3bea52..7bedb1ef0e 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md @@ -10,9 +10,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md) ## Decision -`@deepseek-ai/dsh-tool-schedule` (`packages/schedule/tool-schedule`) contributes the four tools as a preset-level Consumer. It declares `inject = ['tools']` and registers the definitions on `ctx.tools` from inside `ctx.inject(['schedule'], …)`, so the mounting scope is the one that owns them and the registration waits for the Host Schedule service in that same scope. The shipped Web profile mounts the row in its `standard`, `cordis`, and `ptc` presets and omits it from `minimal`. Cordis effect ownership disposes the definitions with the mount, and a composition that never resolves `schedule` registers none of them. The [Web bundle](../../../../packages/bundle/web-app/README.md) owns the `schedule` and `ui-schedule` rows the shipped composition mounts; this record owns the reminder tools' preset placement. +`@deepseek-ai/dsh-tool-schedule` (`packages/schedule/tool-schedule`) contributes `schedule_create`, `schedule_list`, `schedule_view`, and `schedule_edit` as a preset-level Consumer. It declares `inject = ['tools', 'systemPrompt']` and registers the definitions and shared authorization instruction from inside `ctx.inject(['schedule'], …)`, so the mounting scope owns them and registration waits for the Host Schedule service in that same scope. The shipped Web profile mounts the row in its `standard`, `cordis`, and `ptc` presets and omits it from `minimal`. Cordis effect ownership disposes the definitions with the mount, and a composition that never resolves `schedule` registers none of them. The [Web bundle](../../../../packages/bundle/web-app/README.md) owns the `schedule` and `ui-schedule` rows the shipped composition mounts; this record owns the reminder tools' preset placement. -`@deepseek-ai/dsh-schedule` keeps the version-1 storage domain, the Host timer and serialized queue, Host delivery through the Session controller, the Automation page's backing reads, and the `ctx.schedule` interface. `dsh-tool-schedule` is the model-facing consumer of that interface: it checks selector and identity constraints before the service call, reads `exec.agent` for the Session binding, and maps failures that are not `ScheduleInputError` to `internal_error` so storage details never reach the model. +`@deepseek-ai/dsh-schedule` keeps the `schedule` storage domain, now writing v2 and reading v1, the Host timer and serialized queue, Host delivery, the Automation tasks page's backing reads, and the `ctx.schedule` interface. `dsh-tool-schedule` is the model-facing consumer of that interface: it maps RRULE and target inputs to service requests, reads `exec.agent` for the actor and `this_session` destination, and maps undeclared failures to `internal_error` so storage details never reach the model. Management addresses every Host task by id; the target alone selects the receiving Session. ## Alternatives considered @@ -26,9 +26,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md) - Each preset that mounts the row pays the four schemas' fixed token cost, and tool availability is read from the composition instead of inferred from the Host service's presence. - A deployment can mount `dsh-schedule` for storage and delivery without granting its agents model-driven reminder management. - `dsh-schedule` injects no `ctx.tools` and registers no model-facing tool. -- A delegated child inherits the preset's four reminder schemas. `subagentCallerRefusal` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution. -- A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and `ScheduleService.update` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included. +- A delegated child inherits the preset's four reminder schemas. `runForCaller` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use scheduled tasks.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution. +- A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and target changes or arming through `ScheduleService.edit` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included. ## Testing -`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins. +`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every call from a registered delegated child whose scope mounts the tools`, and `packages/subagent/subagent/tests/schedule-tools.spec.ts` runs a delegated child through the tool executor and pins that refusal without a stored task. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins. diff --git a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md index 75b41d8b9e..408eff9a8d 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -`@deepseek-ai/dsh-tool-schedule`(`packages/schedule/tool-schedule`)以 preset 级 Consumer 的身份贡献这四个工具。它声明 `inject = ['tools']`,并在 `ctx.inject(['schedule'], …)` 内通过 `ctx.tools` 注册这些定义,因此拥有它们的是挂载它的作用域,而注册会等待同一作用域中的宿主 Schedule 服务。发布版 Web profile 在其 `standard`、`cordis` 和 `ptc` preset 中挂载该行,`minimal` 不挂载。Cordis 的 effect 所有权随挂载卸载而释放这些定义,而始终未解析出 `schedule` 的组合不会注册其中任何一个。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责随发行版交付的组合挂载的 `schedule` 与 `ui-schedule` 两行;本记录负责提醒工具的 preset 归属。 +`@deepseek-ai/dsh-tool-schedule`(`packages/schedule/tool-schedule`)以 preset 级 Consumer 的身份贡献 `schedule_create`、`schedule_list`、`schedule_view` 和 `schedule_edit`。它声明 `inject = ['tools', 'systemPrompt']`,并在 `ctx.inject(['schedule'], …)` 内注册这些定义和共用授权指令,因此挂载作用域拥有它们,注册会等待同一作用域中的宿主 Schedule 服务。发布版 Web profile 在其 `standard`、`cordis` 和 `ptc` preset 中挂载该行,`minimal` 不挂载。Cordis 的 effect 所有权随挂载卸载而释放这些定义,而始终未解析出 `schedule` 的组合不会注册其中任何一个。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责随发行版交付的组合挂载的 `schedule` 与 `ui-schedule` 两行;本记录负责提醒工具的 preset 归属。 -`@deepseek-ai/dsh-schedule` 保留版本 1 storage domain、宿主定时器与串行队列、经由 Session controller 的宿主投递、自动化任务页面的读取来源,以及 `ctx.schedule` 接口。`dsh-tool-schedule` 是该接口面向模型的消费者:它在调用服务前校验选择器与身份约束,从 `exec.agent` 读取 Session 绑定,并把非 `ScheduleInputError` 的失败映射为 `internal_error`,使存储细节不会到达模型。 +`@deepseek-ai/dsh-schedule` 保留 `schedule` 存储域,现写入 v2 并读取 v1,同时保留宿主定时器与串行队列、宿主投递、自动化任务页面的读取来源,以及 `ctx.schedule` 接口。`dsh-tool-schedule` 是该接口面向模型的消费者:它将 RRULE 与目标输入映射为服务请求,从 `exec.agent` 读取操作者与 `this_session` 目的地,并把未声明的失败映射为 `internal_error`,使存储细节不会到达模型。管理按 id 寻址宿主全部任务;仅目标选择接收 Session。 ## Alternatives considered @@ -26,9 +26,9 @@ Status: implemented - 挂载该行的每个 preset 承担四个 schema 的固定 token 成本,工具可用性取自组合,而不是由宿主服务是否存在推断。 - 部署可以只为存储与投递挂载 `dsh-schedule`,而不授予其 Agent 由模型驱动的提醒管理能力。 - `dsh-schedule` 不注入 `ctx.tools`,也不注册任何面向模型的工具。 -- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `subagentCallerRefusal` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。 -- 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 与 `ScheduleService.update` 会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。 +- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `runForCaller` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use scheduled tasks.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。 +- 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 以及通过 `ScheduleService.edit` 改变目标或启用任务时会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。 ## Testing -`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。 +`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every call from a registered delegated child whose scope mounts the tools`;`packages/subagent/subagent/tests/schedule-tools.spec.ts` 通过工具执行器运行一个被委派的子 agent,并在没有已存任务时固定该拒绝。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.i18n.yaml index 8dcfb5a6a2..2a25da27a7 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.i18n.yaml @@ -6,14 +6,14 @@ en: f524c3b0ee74c586 zh: ec4e85f0fd853532 /agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/problem: - en: c4156e2a8a3ad379 - zh: 992b76509f83d61f + en: 57f2fb0a35f6a420 + zh: 7174afde673c62d6 /agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/decision: - en: b9b203da394f58e5 - zh: ac511458d60dab49 + en: 136514e302d80f4c + zh: b34e140f41547d06 /agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/alternatives-considered: - en: 8074a2b9b9f5d782 - zh: 1604b7ee5d34375e + en: 49cc4e396052fc09 + zh: adb8b01f29ce76fb /agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/consequences: - en: 85e03b541bfce57c - zh: 77decbb804f6a0b9 + en: 55c346fbd5feb417 + zh: 29631345d447f643 diff --git a/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.md b/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.md index 906edabec9..27a4837b83 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.md +++ b/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.md @@ -6,17 +6,17 @@ English | [中文](2026-09-24-scheduled-message-framing.zh.md) ## Problem -Due reminders reach their original Session as user-role messages with producer kind `schedule`. Before this decision, both renderers opened that message with an instruction about how to treat its payload: one-shot delivery asked the model to present `reminder_prompt_json` as untrusted reminder content and not as new user instructions, and recurring batch delivery asked the same for every `reminder_prompt` in `reminders_json`. The message therefore asserted that its own content was untrusted instead of naming where it came from. +Due reminders reach their target Session as user-role messages with producer kind `schedule`. Before this decision, both renderers opened that message with an instruction about how to treat its payload: one-shot delivery asked the model to present `reminder_prompt_json` as untrusted reminder content and not as new user instructions, and recurring batch delivery asked the same for every `reminder_prompt` in `reminders_json`. The message therefore asserted that its own content was untrusted instead of naming where it came from. ## Decision `renderReminderFraming` and `renderRecurringReminderBatchFraming` in [`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) both emit one shared fixed line, `SCHEDULED_MESSAGE_FRAMING`: `This is a scheduled message from the user`. It is the block's second line, after the bracketed marker (`[SCHEDULE REMINDER]`, `[SCHEDULE REMINDER BATCH]`), and the dynamic field lines are unchanged: a one-shot message still appends `schedule_id_json`, `occurrence_at`, and `reminder_prompt_json`, and a recurring batch still appends `reminders_json`. -The fixed line states the origin of the message: a schedule bound to that Session. A model reading a user-role message whose fixed block carries it reads the following reminder payload (`reminder_prompt_json` in a one-shot message, `reminders_json` in a batch) as content the user scheduled, and the Host cannot attest that the user typed it: `schedule_create` and `schedule_update` let the model write the prompt, and a model-written prompt can carry text the model read from the web, files, or command output. The previous line marked that payload untrusted, and this decision drops that instruction, so the change accepts the residual risk that model-authored text arrives under a user-origin line. Reintroduction condition: a demonstrated injection through a model-written prompt restores a clause that scopes the payload as reminder text rather than a new instruction, and the five pinned framing assertions in the package specs plus `expectReminderFraming` in the Web e2e are the check that such a change is deliberate. Framing-line forgery is closed by encoding: the schedule id and prompt are JSON strings, the occurrence instant is a validated canonical RFC 3339 value, and each occupies one line, so a prompt carrying embedded newlines or an `occurrence_at:`-style line cannot add framing lines to the block. The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision, including delivery through the Session controller and the saved delivery records. +The fixed line states the origin of the message: a schedule that delivers to that Session. A model reading a user-role message whose fixed block carries it reads the following reminder payload (`reminder_prompt_json` in a one-shot message, `reminders_json` in a batch) as content the user scheduled, and the Host cannot attest that the user typed it: `schedule_create` and `schedule_edit` let the model write the prompt, and a model-written prompt can carry text the model read from the web, files, or command output. The previous line marked that payload untrusted, and this decision drops that instruction, so the change accepts the residual risk that model-authored text arrives under a user-origin line. Reintroduction condition: a demonstrated injection through a model-written prompt restores a clause that scopes the payload as reminder text rather than a new instruction, and the five pinned framing assertions in the package specs plus `expectReminderFraming` in the Web e2e are the check that such a change is deliberate. Framing-line forgery is closed by encoding: the schedule id and prompt are JSON strings, the occurrence instant is a validated canonical RFC 3339 value, and each occupies one line, so a prompt carrying embedded newlines or an `occurrence_at:`-style line cannot add framing lines to the block. The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision, including delivery through the Session controller and the saved delivery records. ## Alternatives considered -**Keep the untrusted-content instruction.** It asserted that the prompt was untrusted input, which the delivered message does not need: the prompt is written in the Session the schedule is bound to — by the model through `schedule_create` and `schedule_update`, or by the user in the Web task detail — and the delivery is an ordinary user-role message there. +**Keep the untrusted-content instruction.** It asserted that the prompt was untrusted input, which the delivered message does not need: the model writes the prompt through `schedule_create` and `schedule_edit`, a human caller writes it through the Web task detail or Remote `create` and `edit`, and the delivery is an ordinary user-role message in its target Session. **Change only the recurring batch line.** One-shot and recurring delivery would then disagree about the origin of the same kind of message, and each later wording change would need both renderers edited separately. Both paths now read one constant. @@ -24,6 +24,6 @@ The fixed line states the origin of the message: a schedule bound to that Sessio Model-visible reminder text changes for both delivery paths, so a Session log recorded before this decision reconstructs the previous framing, and the Web notice that renders a delivered message shows the new line when expanded. Nothing else moves: no session event, storage field, client code, protocol, or durable format changes, and the delivered text remains reconstructable from the log. -The prompt text carries no authorship guarantee. It is written in the Session the schedule is bound to, by the model through `schedule_create` and `schedule_update` or by the user in the Web task detail, and a model-written reminder can quote text the model read from the web, files, or command output. The task's Session binding scopes which Session's model reaches the task; it is not caller authorization and says nothing about where the prompt content came from. The Decision holds the accepted risk and its reintroduction condition. +The prompt text carries no authorship guarantee. The model writes it through `schedule_create` and `schedule_edit`, a human caller writes it through the Web task detail or Remote `create` and `edit`, and a model-written reminder can quote text the model read from the web, files, or command output. Those tools reach every Host task by id from any Session whose preset mounts them, so the task's target Session is not caller authorization and says nothing about where the prompt content came from. The Decision holds the accepted risk and its reintroduction condition. No recorded-session snapshot covers a delivered reminder, and this change adds none: no committed fixture contains a `schedule` message, and `snapshots/web/schedule-catalog` records catalog queries only. Two harness capabilities are missing for such a fixture: no scenario patch controls the wall clock that `packages/schedule/schedule/src/runtime.ts` reads for delivery, and the snapshot harness seeds Sessions rather than the schedule storage domain that holds Host tasks, so those rows exist only where a Web test writes them by hand. This change records the missing harness support instead of a fixture. The delivered text is pinned by the five full-string assertions in `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` and by `expectReminderFraming` in `apps/web/tests/schedule-after.e2e.ts`, which asserts the line in the assembled request for the one-shot and recurring paths. diff --git a/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.zh.md b/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.zh.md index 7aed47c1d0..b2060b019c 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.zh.md @@ -6,17 +6,17 @@ Status: implemented ## Problem -到期的提醒以生产者 kind 为 `schedule` 的 user-role 消息进入其原 Session。在本决策之前,两个渲染器都用一句关于如何处理其载荷的固定指令开篇:单次投递要求模型把 `reminder_prompt_json` 作为不可信的提醒内容呈现、而不是新的用户指令;周期批次投递对 `reminders_json` 中的每个 `reminder_prompt` 提出同样的要求。于是这条消息断言自身内容不可信,而没有说明它来自哪里。 +到期的提醒以生产者 kind 为 `schedule` 的 user-role 消息进入其目标 Session。在本决策之前,两个渲染器都用一句关于如何处理其载荷的固定指令开篇:单次投递要求模型把 `reminder_prompt_json` 作为不可信的提醒内容呈现、而不是新的用户指令;周期批次投递对 `reminders_json` 中的每个 `reminder_prompt` 提出同样的要求。于是这条消息断言自身内容不可信,而没有说明它来自哪里。 ## Decision [`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) 中的 `renderReminderFraming` 与 `renderRecurringReminderBatchFraming` 都输出同一条固定行,即模块常量 `SCHEDULED_MESSAGE_FRAMING`:`This is a scheduled message from the user`;它是该块的第二行,位于方括号标记(`[SCHEDULE REMINDER]`、`[SCHEDULE REMINDER BATCH]`)之后。动态字段行不变:单次消息仍追加 `schedule_id_json`、`occurrence_at`、`reminder_prompt_json`,周期批次仍追加 `reminders_json`。 -这条固定行说明消息的来源:绑定到该 Session 的一条定时任务。模型读到固定块中带有它的 user-role 消息时,会把随后的提醒载荷(单次消息的 `reminder_prompt_json`、批次的 `reminders_json`)当作用户安排的内容,而 Host 无法证明该文本由用户键入:`schedule_create` 与 `schedule_update` 允许模型写入提示词,模型写入的提示词可以带上模型从网页、文件或命令输出中读到的文本。旧固定行把该载荷标记为不可信,本决策有意去掉这一指令,因此接受「模型生成的文本以用户来源行投递」这一残留风险。再引入条件:一旦出现经由模型写入提示词完成注入的实例,就恢复一句把载荷限定为提醒文本、而非新指令的说明;包内五处固定断言与 Web e2e 的 `expectReminderFraming` 是「此类改动必须是有意为之」的检查。固定行无法被动态字段伪造,这由编码保证:schedule id 与提示词是 JSON 字符串,发生时点是经校验的规范 RFC 3339 值,且各自只占一行,因此带有换行或形如 `occurrence_at:` 的提示词无法向该块增加固定行。[Host 拥有的定时消息](2026-09-16-host-schedule-storage.zh.md) 记下的其余决策不变,包括通过 Session controller 投递以及保存的投递记录。 +这条固定行说明消息的来源:投递到该 Session 的一条定时任务。模型读到固定块中带有它的 user-role 消息时,会把随后的提醒载荷(单次消息的 `reminder_prompt_json`、批次的 `reminders_json`)当作用户安排的内容,而 Host 无法证明该文本由用户键入:`schedule_create` 与 `schedule_edit` 允许模型写入提示词,模型写入的提示词可以带上模型从网页、文件或命令输出中读到的文本。旧固定行把该载荷标记为不可信,本决策有意去掉这一指令,因此接受「模型生成的文本以用户来源行投递」这一残留风险。再引入条件:一旦出现经由模型写入提示词完成注入的实例,就恢复一句把载荷限定为提醒文本、而非新指令的说明;包内五处固定断言与 Web e2e 的 `expectReminderFraming` 是「此类改动必须是有意为之」的检查。固定行无法被动态字段伪造,这由编码保证:schedule id 与提示词是 JSON 字符串,发生时点是经校验的规范 RFC 3339 值,且各自只占一行,因此带有换行或形如 `occurrence_at:` 的提示词无法向该块增加固定行。[Host 拥有的定时消息](2026-09-16-host-schedule-storage.zh.md) 记下的其余决策不变,包括通过 Session controller 投递以及保存的投递记录。 ## Alternatives considered -**保留「不可信内容」指令。** 它断言提示词是不可信输入,而投递的消息并不需要这一断言:提示词写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——投递本身就是该 Session 中的普通 user-role 消息。 +**保留「不可信内容」指令。** 它断言提示词是不可信输入,而投递的消息并不需要这一断言:模型通过 `schedule_create` 与 `schedule_edit` 写入提示词,人工调用方通过 Web 任务详情或 Remote `create` 与 `edit` 写入它,投递本身就是目标 Session 中的普通 user-role 消息。 **只改周期批次那一行。** 单次与周期投递会对同类消息的来源给出不同说法,此后每次改措辞都要分别改两个渲染器。现在两条路径读同一个常量。 @@ -24,6 +24,6 @@ Status: implemented 两个投递路径的模型可见提醒文本都改变,因此在该决策之前记录的 Session 日志重建出的是旧的固定文本;Web 中渲染该投递消息的通知在展开时显示新的一行。其余不动:没有 session 事件、存储字段、客户端代码、协议或持久化格式变化,投递文本仍可从日志重建。 -提示词文本不携带作者保证。它写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——模型创建的提醒可以把模型从网页、文件或命令输出中读到的文本原样写入。任务的 Session 绑定限定的是哪个 Session 的模型能访问该任务;它不是调用方鉴权,也不说明提示词内容的来源。本决策中记下该残留风险与再引入条件。 +提示词文本不携带作者保证。模型通过 `schedule_create` 与 `schedule_edit` 写入它,人工调用方通过 Web 任务详情或 Remote `create` 与 `edit` 写入它;模型创建的提醒可以把模型从网页、文件或命令输出中读到的文本原样写入。这些工具可以从任何挂载它们的 preset 所属的 Session 按 id 访问 Host 的每个任务,因此任务的目标 Session 不是调用方鉴权,也不说明提示词内容的来源。本决策中记下该残留风险与再引入条件。 没有录制会话快照覆盖提醒投递,本次也没有新增:已提交夹具中没有 `schedule` 消息,`snapshots/web/schedule-catalog` 只记录任务查询。构成该夹具还缺两项 harness 能力:没有场景补丁能控制 `packages/schedule/schedule/src/runtime.ts` 读取的墙上时钟;快照 harness 播种的是 Session,而不是承载 Host 任务的 schedule storage domain,那些任务行只在 Web 测试里手工写入。因此本次记录缺失的 harness 支持,而不新增夹具。投递文本改由 `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` 中五处整串断言,以及 `apps/web/tests/schedule-after.e2e.ts` 中在装配后请求上断言该行、覆盖单次与周期两条路径的 `expectReminderFraming` 固定。 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml index 8b028995ae..5207fd6764 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml @@ -9,11 +9,11 @@ en: abd0263d14861926 zh: e1736cd08e87f795 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: fdd39b7e8fa89d70 - zh: 4a0dfc81509e5485 + en: f1c2b98bf56cdba3 + zh: 7e9444f8e73a4834 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: - en: 1737484c099fab52 - zh: 303b144c52d167bd + en: 3964f7d653747b55 + zh: b50636faea4917ef /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 79d8d66071453d1f - zh: f940f0b6d74148d6 + en: 9fdaa9da9c9952eb + zh: b941f68c9c1bb994 diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md index 2cc2c8be43..9592d362f0 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -10,11 +10,11 @@ English | [中文](2026-09-24-shared-time-vocabulary.zh.md) ## Decision -`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume. +`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds strict instant and zone parsers, `src/duration.ts` holds the span type, and `src/cron.ts` / `src/rrule.ts` hold bounded recurrence values. `src/index.ts` exports them and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume. The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, so year 0 is refused by `parseOffsetInstant` and `parseLocalCalendar` alike, while `resolveLocalInstant` accepts any year a real `Temporal` date carries because its callers derive local dates at the UTC edges. `LocalCalendarTime.year` records that split. -Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound. +Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they call the shared parser and map its reason. Pure Cron/RRULE parsing and bounded occurrence searches belong to the library, take explicit reference times, and validate committed targets with `fourDigitYearEpoch`. Schedule owns durable records, the fold and transition authority, injection-resistant framing, daily and weekly date walks, delivery limits, and domain errors. The shared values own no task state, wall clock, or timers. `dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers. @@ -22,7 +22,7 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d The last copy of the canonical instant profile moves too. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` validated a stored schedule instant with its own regular expression and round trip; the shared `fourDigitYearEpoch` owns that same profile, so the migration now asks it and keeps its own error type and message. Because that migration reads released logs, its accepted set is a compatibility promise: `packages/session/session-format-v0-to-v1/tests/validation.spec.ts` accepts the four-digit-year bounds and refuses each spelling a regular expression alone would admit (`0000`, `2026-02-30`, a four-digit fraction, a numeric offset, surrounding whitespace). -The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads. +The maintained `@js-temporal/polyfill` 0.5.1 is a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`; `rrule` supplies RRULE enumeration behind the library's strict syntax and cost checks. Local resolution uses Temporal, so the Host's IANA data decides gaps and overlaps and no caller owns offset sampling. The util group's charter forbids harness dependencies so any group may depend on util; it permits external packages, which this package's own description and README identify. Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. @@ -38,13 +38,13 @@ Observable behavior does not change. Every accepted input resolves to the same e **Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name. -**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need. +**Share model-facing formatting too.** `dsh-time-context` owns its formatter and zone policy text. Moving that presentation into the library would add consumer policy to the shared arithmetic, against the package rule that ties each abstraction to a current owner and need. ## Consequences The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner. -The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message. +The cost is a wider published surface and a heavier dependency graph: the root entry resolves `@js-temporal/polyfill` and `rrule` even for consumers that only need a zone or span. The README records that cost and the bounded recurrence profiles. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message. A value type publishes what its consumers call, so the span exposes the constructor and the split that `dsh-time-context` uses and nothing else. A conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it; the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation. diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md index 72d4e6b12b..619cd29751 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md @@ -10,11 +10,11 @@ Status: implemented ## Decision -`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 +`dsh-util-time` 拥有各边界所接收的时间词汇。严格时刻与时区解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,有界重复规则值位于 `src/cron.ts` / `src/rrule.ts`。`src/index.ts` 导出这些入口,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,所以年份 0 被 `parseOffsetInstant` 与 `parseLocalCalendar` 同样拒绝;而 `resolveLocalInstant` 接受真实 `Temporal` 日期携带的任何年份,因为它的调用方需要在 UTC 边界推导本地日期。`LocalCalendarTime.year` 记录了这一分工。 -Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 +Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们调用共享解析器并映射其原因。纯 Cron/RRULE 解析与有界发生时点搜索归本库所有,接收显式参考时间,并通过 `fourDigitYearEpoch` 校验已提交时点。Schedule 拥有持久记录、折叠与状态转移权威、抗注入的框架文本、每日与每周日期遍历、投递限制及领域错误。共享值不拥有任务状态、墙钟或定时器。 `dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。 @@ -22,7 +22,7 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 最后一份规范时刻剖面副本也一并迁移。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 原本用自己的一段正则与往返比对校验持久记录里的调度时刻;共享的 `fourDigitYearEpoch` 拥有同一剖面,因此该迁移改为询问它,并保留自己的错误类型与文案。由于这个迁移要读取已发布的日志,其接受集是兼容性承诺:`packages/session/session-format-v0-to-v1/tests/validation.spec.ts` 接受四位年份两端,并拒绝只靠正则会放进来的每种拼写(`0000`、`2026-02-30`、四位小数、数字偏移、首尾空白)。 -受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。 +受维护的 `@js-temporal/polyfill` 0.5.1 是 `dsh-util-time` 与 `dsh-schedule` 的运行时依赖;`rrule` 在本库的严格语法与成本检查之后提供 RRULE 枚举。本地解析使用 Temporal,因此缺口与重叠由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。util 组的约定禁止 harness 依赖,以便任何组都能依赖 util;它允许外部包,本包自己的描述与 README 写明了这些依赖。 可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 @@ -38,13 +38,13 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec **用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。 -**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。 +**同时共享面向模型的格式化。** `dsh-time-context` 拥有自己的格式化器与时区策略文本。把这些呈现迁入本库会向共享算术加入消费方策略,违背“每项抽象都绑定当前所有者与需求”的包规则。 ## Consequences 时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它仍然导入本库的两个边界常量用于其 UTC 边界日期运算,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。 -代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。 +代价是更宽的已发布接口与更重的依赖图:根入口会解析 `@js-temporal/polyfill` 和 `rrule`,只需要时区或跨度的消费方也一样。README 记录了这些成本与有界重复规则子集。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。 值类型只发布消费方会调用的部分,因此这个时间段只暴露 `dsh-time-context` 用到的构造入口与拆解,其余不发布。某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地;`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段就是将来会要求第二个单位换算的那些跨度,而类已经携带的范围约束与冻结正是让这次添加保持很小的原因。包 README 把这些尚未迁移的边界记为已知限制。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml index 4a2fe2ba9d..badb03541e 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml @@ -6,14 +6,14 @@ en: 9163af91ea0e030d zh: 92e44043b999d32f /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem: - en: 6fac3c73a4415c83 - zh: f65b61cc1705348b + en: 32ba2b2b42e8deb5 + zh: 6a6532561f81b8e2 /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision: - en: 458416ed7e47f644 - zh: 8b4dd820228e9dfd + en: 08a54b37e157da29 + zh: 65f633055d9df242 /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered: - en: b731254983be656d - zh: 5dee2b09f7faa537 + en: 6da221f6b821ea44 + zh: b15774011abed7ed /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences: - en: 798faeaa0b406eac - zh: 97bbfdaebc84b1d4 + en: c90a122c1f33bb83 + zh: 0175ca0f599d086b diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md index c3e74990d7..2399e3f1fc 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md @@ -6,17 +6,17 @@ English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md) ## Problem -`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed. +`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The DeepSeek web providers authenticate an operation with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. Without a readiness check, a Session on an API-key route without a key is offered a tool whose every call fails with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Fetch API of `dsh-web-deepseek-api`, which the user selects separately from the search provider ([provider selection](2026-10-29-web-provider-selection.md)), needs the same credential, so such a Session with it selected would also be offered a `web_fetch` whose every call fails. ## Decision Each role of the web capability seam owns one part of a per-step check: - **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`. -- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section. +- **Service Provider.** Each DeepSeek provider checks its credentials in the order its operation resolves them. `dsh-web-search-deepseek` checks, on the `deepseek-account` route, whether `ctx.deepseekAccount.resolveToken()` returns a token for its Messages endpoint, and then its literal `apiKey` or its `apiKeyEnv` reference. The Search and Fetch API providers of `dsh-web-deepseek-api` resolve the same plan as the operation ([credential resolution](2026-10-29-web-provider-selection.md)): a capability with its own YAML fields checks only the key under its `*ApiKeyEnv`; otherwise, on the account route with the account model route mounted, a token for that route's `/v1/tools` base counts, and then the key of the `deepseek-official` route, or `DEEPSEEK_API_KEY` without that route. A key counts when `ctx.credentials.describe()` reports its reference configured (without that service, when the launching environment carries it); the [shared credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) perform these presence checks. A failed account-token or credential-store read answers false, because the operation fails on that read instead of trying the next credential. The providers name no tool or prompt section. - **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set. -The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway. +The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. A Search or Fetch API capability with its own YAML fields ignores the route and is ready exactly when its own key is configured. Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none. @@ -24,7 +24,7 @@ The request header records the assembled tools, so the agent loop announces each ## Alternatives considered -**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed. +**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as one a plugin registers, whose searches would succeed. **Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation. @@ -36,9 +36,9 @@ The request header records the assembled tools, so the agent loop announces each **Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case. -**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find. +**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Search and Fetch APIs send the chat route's own key, and the settings page saves the legacy Messages search key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so withholding them would leave a configured key unable to serve another model; a `routes` setting that opens them would be one more thing a user has to find. -**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint. +**Compare the official chat route's endpoint with the web endpoint and withhold the tools when they differ.** It would keep a gateway's key away from DeepSeek, but it would also withhold web tools from users who chat through a proxy or development endpoint with a key the web endpoint accepts. The Search and Fetch APIs instead derive their endpoint from the origin of the route whose key they send, so the key and endpoint come from one route; legacy Messages search keeps its own endpoint, where a gateway user's search fails. **Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt. @@ -46,6 +46,6 @@ The request header records the assembled tools, so the agent loop announces each ## Consequences -`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package. +`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The HTTP fetch provider implements no `ready()`, so `web_fetch` stays admitted whenever HTTP fetch is selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key. A deployment whose official route chats through a gateway sends the gateway's key to that gateway's origin with `/v1/tools` for the Search and Fetch APIs, and to the configured Messages endpoint, the official one by default, for legacy Messages search, where each search fails unless that endpoint accepts the key. The Search and Fetch APIs read the DeepSeek model routes' Loader entries through the model provider directory. -Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. +Unit tests pin each role: [web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) cover each credential case of `ready()` on the account route and on other routes, including readiness that matches each operation, and [tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md index 9c4b0f6948..61f8acb6f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md @@ -6,17 +6,17 @@ Status: implemented ## Problem -`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。 +`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。DeepSeek Web 提供方只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 为操作鉴权;其他路由都需要已配置的 API 密钥。没有就绪检查时,API 密钥路由上没有密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。`dsh-web-deepseek-api` 的 Fetch API 由用户与搜索提供方分别选择([提供方选择](2026-10-29-web-provider-selection.zh.md)),它需要同一份凭据,因此这样的会话选中它时,还会获得一个每次调用都失败的 `web_fetch`。 ## Decision web 能力 seam 的每个角色各负责逐步骤检查的一部分: - **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。 -- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。 +- **Service Provider。** 每个 DeepSeek 提供方都按其操作解析凭据的顺序检查凭据。`dsh-web-search-deepseek` 在 `deepseek-account` 路由上检查 `ctx.deepseekAccount.resolveToken()` 是否为其 Messages 端点返回 token,然后检查其字面 `apiKey` 或 `apiKeyEnv` 引用。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方解析与操作相同的方案([凭据解析](2026-10-29-web-provider-selection.zh.md)):在 YAML 中设置了单独字段的能力只检查其 `*ApiKeyEnv` 下的密钥;否则,在账号路由上且账号模型路由已挂载时,该路由 `/v1/tools` 基础地址的 token 算数,然后是 `deepseek-official` 路由的密钥,没有该路由时为 `DEEPSEEK_API_KEY`。`ctx.credentials.describe()` 报告其引用已配置时(没有该服务时,启动环境中存在该变量),密钥即算存在;这些存在性检查由[共享凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)执行。读取账号 token 或凭据存储失败时回答 false,因为操作会在该次读取上失败,而不会尝试下一种凭据。提供方不点名任何工具或提示词段落。 - **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。 -路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。 +路由只决定是否先检查账号 token 再检查 API 密钥,这发生在 `deepseek-account` 路由上;API 密钥在每条路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。在 YAML 中设置了单独字段的 Search 或 Fetch API 能力不看路由,恰在其自身密钥已配置时就绪。 只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 @@ -24,7 +24,7 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: ## Alternatives considered -**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。 +**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如插件注册的提供方),它也会移除该工具。 **扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。 @@ -36,9 +36,9 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: **只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。 -**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 +**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Search 与 Fetch API 发送聊天路由自己的密钥,设置页把旧版 Messages 搜索的密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此撤下工具会让已配置的密钥无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 -**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。 +**比较官方聊天路由的端点与 Web 端点,二者不同时撤下工具。** 这能让网关的密钥远离 DeepSeek,但也会让经代理或开发端点对话、而密钥能被 Web 端点接受的用户失去网页工具。Search 与 Fetch API 改为从其所发送密钥的路由的 origin 推导端点,因此密钥与端点来自同一条路由;旧版 Messages 搜索保留自己的端点,网关用户的搜索会在那里失败。 **保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。 @@ -46,6 +46,6 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: ## Consequences -`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。 +`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。HTTP 抓取提供方没有实现 `ready()`,因此只要选中 HTTP 抓取,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费。官方路由经网关对话的部署,对 Search 与 Fetch API 会把网关的密钥发往该网关 origin 加 `/v1/tools`,对旧版 Messages 搜索则发往配置的 Messages 端点(默认是官方端点),除非该端点接受这个密钥,否则每次搜索都会失败。Search 与 Fetch API 通过模型提供方目录读取 DeepSeek 模型路由的 Loader 条目。 -单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 +单元测试分别固定每个角色:[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 与 [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,包括就绪判断与每次操作一致,[tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml index b22fc768ec..3bdacb079c 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml @@ -9,14 +9,14 @@ en: 8112c1c059d1cf10 zh: 852d23c56a93a555 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/decision: - en: 90d74300f045bf6e - zh: 088e88944478ca2a + en: 3cf1a8f986cea871 + zh: 3105cf2608108ecd /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/deferred-naming-cleanup: - en: 4c428f65ef611d07 - zh: 1fdc5467b0203d08 + en: 7f59c9d14a36e133 + zh: 2e7829c59b4fcd24 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/alternatives-considered: en: 65af55b5775c9bc5 zh: 2ea75b3fd33ac0c2 /agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/consequences: - en: e8e96b0354f1a3c6 - zh: ad1bfe3600e14ff1 + en: 08613c24e7c552ab + zh: 6f2509d3e61cb2c7 diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md index c39f289a39..a6b66bc1fb 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md @@ -10,7 +10,7 @@ Moving a capability into the experimental directory changes its ownership and su ## Decision -The moved packages retain the published names declared by the [experimental naming policy](../../../../scripts/experimental-package-policy.ts). That typed directory-to-name map is the sole maintained inventory of naming exceptions. Package manifests, consumers, installation checks, and bundler checks agree with it; a missing, moved, or duplicated exception is rejected. [Experimental status](../../../../packages/experimental/README.md#status) remains independent of spelling, Official discovery, and explicit selection. +The moved packages retain the published names declared by the [experimental naming policy](../../../../scripts/experimental-package-policy.ts). That typed directory-to-name map is the sole maintained inventory of unprefixed experimental names; under the [unprefixed naming decision](2026-11-04-unprefixed-experimental-package-names.md), every new experimental package joins it. Package manifests, consumers, installation checks, and bundler checks agree with it; a missing, moved, or duplicated entry is rejected. [Experimental status](../../../../packages/experimental/README.md#status) remains independent of spelling, Official discovery, and explicit selection. Existing badge, Ralph, and native delegation row IDs remain valid Host row selectors. New isolation groups and newly introduced capabilities retain their new IDs. Later user layers still replace configuration or disable rows. Selecting an optional bundle is still explicit: a patch targeting an absent row does not select its bundle. @@ -20,10 +20,9 @@ Compatibility protection covers Web and Desktop profiles, including existing nat | Retained identity | Unconstrained cleanup | Why it is deferred | |---|---|---| -| Published names in the naming-exception map | Give every experimental package the `@deepseek-ai/dsh-experimental-` prefix | Existing dependencies, imports, module declarations, and profile files use the established names | | Existing badge, Ralph, and native-tool row IDs | Give optional contributions consistently distinct `optional-*` selectors | Existing user overrides use those IDs; the ownership move does not require a new selector | -These naming changes are deferred, not prerequisites for experimental classification. A later cleanup requires an explicit release and migration decision, an inventory of affected Web/Desktop configurations, and installed-profile upgrade evidence. Directory moves or pre-stable API status alone do not retire these exceptions. The package list stays in the typed policy rather than being copied into this note or package READMEs. +These naming changes are deferred, not prerequisites for experimental classification. A later cleanup requires an explicit release and migration decision, an inventory of affected Web/Desktop configurations, and installed-profile upgrade evidence. Directory moves or pre-stable API status alone do not retire these row IDs. The package list stays in the typed policy rather than being copied into this note or package READMEs. ## Alternatives considered @@ -35,6 +34,6 @@ These naming changes are deferred, not prerequisites for experimental classifica ## Consequences -The repository carries a bounded naming-exception map and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides and disabled state. No general alias or automatic profile migration mechanism is introduced. +The repository carries a typed map of unprefixed experimental names and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides and disabled state. No general alias or automatic profile migration mechanism is introduced. The [classification decision](../process/2026-10-05-product-package-classification.md), [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md), and [on-demand discovery decision](2026-10-05-official-on-demand-bundles.md) retain their separate responsibilities. Released Session generations and persisted hook event names remain protected independently of this profile compatibility scope. diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md index 4b58743cb5..6a34639ec6 100644 --- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -迁移的包保留[实验包命名策略](../../../../scripts/experimental-package-policy.ts)声明的已发布名称。这个带类型的目录到包名映射是命名例外唯一手工维护的清单。包清单、消费者、安装检查和打包检查与其保持一致;缺失、迁移到其他位置或重复归属的例外会被拒绝。[实验状态](../../../../packages/experimental/README.zh.md#status)仍独立于名称拼写、Official 发现和显式选择。 +迁移的包保留[实验包命名策略](../../../../scripts/experimental-package-policy.ts)声明的已发布名称。这个带类型的目录到包名映射是不带前缀的实验包名唯一手工维护的清单;按照[不带前缀命名决策](2026-11-04-unprefixed-experimental-package-names.zh.md),每个新实验包都加入其中。包清单、消费者、安装检查和打包检查与其保持一致;缺失、迁移到其他位置或重复归属的条目会被拒绝。[实验状态](../../../../packages/experimental/README.zh.md#status)仍独立于名称拼写、Official 发现和显式选择。 既有徽章、Ralph 和原生委派行 ID 作为 Host 行选择器保持有效。新增隔离组和新引入的能力保留各自的新 ID。后续用户层仍可替换配置或禁用行。可选 bundle 仍须显式选择:指向缺失行的 patch 不会自动选择其 bundle。 @@ -20,10 +20,9 @@ Status: implemented | 保留的身份 | 不受兼容性约束时的清理 | 暂缓原因 | |---|---|---| -| 命名例外映射中的已发布名称 | 为每个实验包使用 `@deepseek-ai/dsh-experimental-` 前缀 | 既有依赖、导入、模块声明和 profile 文件使用原有名称 | | 既有徽章、Ralph 和原生工具行 ID | 为可选贡献统一使用独立的 `optional-*` 选择器 | 既有用户覆盖使用这些 ID;归属迁移不要求新的选择器 | -这些命名变更被暂缓,不是实验分类的前提。后续清理需要明确的发布与迁移决策、受影响 Web/Desktop 配置清单,以及已安装 profile 的升级验证。仅目录迁移或公共 API 处于稳定前阶段,不足以删除这些例外。包清单保留在带类型的策略中,不复制到本记录或各包 README。 +这些命名变更被暂缓,不是实验分类的前提。后续清理需要明确的发布与迁移决策、受影响 Web/Desktop 配置清单,以及已安装 profile 的升级验证。仅目录迁移或公共 API 处于稳定前阶段,不足以删除这些行 ID。包清单保留在带类型的策略中,不复制到本记录或各包 README。 ## 考虑过的替代方案 @@ -35,6 +34,6 @@ Status: implemented ## 后果 -仓库维护范围明确的命名例外映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置和禁用状态的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。 +仓库维护带类型的不带前缀实验包名映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置和禁用状态的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。 [分类决策](../process/2026-10-05-product-package-classification.zh.md)、[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)和[按需发现决策](2026-10-05-official-on-demand-bundles.zh.md)继续承担各自独立的职责。已发布 Session 代际和持久化 hook 事件名的保护独立于此 profile 兼容范围。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml index dc596b4fae..5494e130cf 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml @@ -9,8 +9,8 @@ en: 386864a282e5ad58 zh: a4ea75ec70eb7778 /agent-note-shared-native-cua-process-with-agent-scoped-state/decision: - en: 11bab6870c3d802f - zh: a22114f2217a3a41 + en: 60ec2276ed0bff33 + zh: 5fb48cc787194254 /agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered: en: 883f71387f599160 zh: 4b5f1450be799397 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md index 8e986aaa2f..ff06f52dc1 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md @@ -12,7 +12,7 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service. -The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures. +The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Source checkouts can keep grants with the [development signing identity](2026-10-29-cua-dev-signing-identity.md). Installed packages retain their distributed signatures. Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization. diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md index 371ae5cc3a..2fa2f9b07f 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md @@ -12,7 +12,7 @@ Status: implemented 同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。 -服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。 +服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。源码检出可以用[开发签名身份](2026-10-29-cua-dev-signing-identity.zh.md)保留授权。安装版保留分发时的签名。 每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml index afcfe07ab3..979033451f 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -9,11 +9,11 @@ en: 47ba548b869be190 zh: c2e5038a56fe741c /agent-note-asynchronous-projection-registration-and-retained-readers/decision: - en: be0d17bfbd1b59d4 - zh: 7a23bccdf95c21ff + en: a6f9ae8a53af5e4b + zh: 0aece791574ef060 /agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: - en: e7c66403fab991d2 - zh: 1580ec29116c7d42 + en: 12e0f5fc9638acc6 + zh: 24213550da27b70b /agent-note-asynchronous-projection-registration-and-retained-readers/consequences: - en: 31ce6f02dd3bf39d - zh: bcd29b2146f689b5 + en: f5c294608d87844f + zh: 24488e439a04d81a diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md index 813b1dffd1..9d19c4e94b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -12,7 +12,7 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a `SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. -A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. +A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives and snapshots. Checkpoints keep each Session's last cell of the released unit until its key registers again: the registry no longer advances that cell, while a retained handle read still advances it forward, so a writer that runs after the owner unloads still stores the state already folded. Under the Cordis rc.11 teardown order, Web-layer plugins release their units before the Sessions they describe detach, and the final projection-cache write would otherwise drop those rows. Stored rows can therefore lag the log; the cache promises only possibly stale rows. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. @@ -20,6 +20,10 @@ Plugins compiled against the synchronous API keep running during migration. The ## Alternatives considered +**Detach owned Sessions before unregistering.** Other Web-layer plugins unload in earlier fibers under the rc.11 teardown order, and plugin reload or disable reaches the same timing, so ordering one owner cannot keep every row. + +**Checkpoint a released unit only while its cell is at the Session cursor.** A Session with a running turn at shutdown appends teardown events after its units are released, so its rows would be dropped again. + **Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order. **Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness. @@ -36,4 +40,4 @@ Plugins compiled against the synchronous API keep running during migration. The ## Consequences -Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls. +Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. The registry also retains a released predecessor, with its definition closures and weakly keyed cells, until the same key registers again, so a key that a reloaded plugin renames or stops registering keeps the old definition referenced for the process lifetime. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md index a80721d82d..56879ebc99 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -12,7 +12,7 @@ Status: implemented `SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 -句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 +句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进和快照中移除单元。检查点仍保留每个 Session 中已释放单元的最后一个 cell,直到它的 key 再次注册:注册表不再推进这个 cell,但已保留的 handle 读取时仍会将其向前推进,因此 owner 卸载后才运行的写入方仍会存下已经折叠出的状态。按 Cordis rc.11 的卸载顺序,Web 层插件会在它们所描述的 Session detach 之前释放单元,否则 projection cache 的最终写入会丢掉这些行。因此存储的行可能落后于日志;cache 只承诺行可能过时。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 @@ -20,6 +20,10 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)` ## Alternatives considered +**卸载前先 detach 自己拥有的 Session。** 按 rc.11 的卸载顺序,其他 Web 层插件在更早卸载的 fiber 里,插件重载或禁用也会碰上同样的时机,调整单个 owner 的顺序保不住所有行。 + +**只在已释放单元的 cell 位于 Session 游标时才写入检查点。** 关停时正在运行一轮的会话会在单元释放后追加 teardown 事件,因此它的这些行会再次被丢掉。 + **保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。 **返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。 @@ -36,4 +40,4 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)` ## Consequences -投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。 +投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册表自身也会保留已释放的前任及其定义闭包和弱键单元,直到同一 key 再次注册;因此重载后改名或不再注册的 key 会在进程生命周期内一直引用旧定义。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml index 65205260c3..f1c602359c 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml @@ -2,18 +2,18 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md -/agent-note-team-identity-in-required-runtime-context: - en: 8c88759320ae8481 - zh: 4068b18723907eb9 -/agent-note-team-identity-in-required-runtime-context/problem: - en: c9a81cb731ddf7b2 - zh: 428c0e6f8937d7c5 -/agent-note-team-identity-in-required-runtime-context/decision: - en: 6c73479973c1e378 - zh: 98677c77f77f8d81 -/agent-note-team-identity-in-required-runtime-context/alternatives-considered: +/agent-note-subagent-identity-in-required-runtime-context: + en: ce8f378b6e7b57fa + zh: 26bd0415b4dba94a +/agent-note-subagent-identity-in-required-runtime-context/problem: + en: f944254565541454 + zh: 27fa9f67c671dec7 +/agent-note-subagent-identity-in-required-runtime-context/decision: + en: 85100936f97a139c + zh: 614081943944e28b +/agent-note-subagent-identity-in-required-runtime-context/alternatives-considered: en: 774566ffbf9e9db8 zh: ea761d876af462f2 -/agent-note-team-identity-in-required-runtime-context/consequences: - en: 18d1aa67936f1227 - zh: 7e06c1f51553ecdd +/agent-note-subagent-identity-in-required-runtime-context/consequences: + en: 2c01a24cd8cc4556 + zh: 37b5d1ca1173318a diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md index 39794f02d3..fb8e92e811 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md @@ -1,4 +1,4 @@ -# Agent Note: Team identity in required runtime context +# Agent Note: Subagent identity in required runtime context Status: implemented @@ -6,15 +6,15 @@ English | [中文](2026-10-13-team-identity-runtime-context.zh.md) ## Problem -A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect current membership while keeping shared system instructions uniform. +A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect the current subagent tree while keeping shared system instructions uniform. ## Decision -`tool-agent-team` contributes `team:identity` through the Agent-scoped `systemPrompt.context()` registry with `required: true`. Its provider reads the existing Team membership and renders the member's role, name, and addressing instructions. Optional-context suppression cannot remove it. Fixed collaboration policy stays in the system prompt; roster and task state remain tool queries. Initial tasks contain only their supplied content. +`tool-subagent-control` contributes required `subagent:identity` through `systemPrompt.context()` in ordinary subagent and Team compositions. It derives identity from `ctx.subagents.rootOf(agent)`: the root sees its own Session id, and descendants see their own, direct parent, and root ids. An unavailable root is reported explicitly. Optional-context suppression and Team mode do not remove identity. Agents receive messaging and discovery guidance only while the root is available and each corresponding standard tool is admitted. Team task policy stays in the shared system prompt; task state remains a tool query. The loop owns logging, retained-snapshot comparison, and re-admission after compaction. A changed role appends the current full runtime-context snapshot after inherited history; unchanged retained context adds no message. No Team-specific retention state, event type, or log migration is introduced. Existing recorded reminders remain historical messages. -This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity); its roster, mailbox, and task decisions remain active. +This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity). [Task-board wait coordination](2026-10-19-team-task-board-inbox.md) keeps ordinary inbox waiting independent of Team-only model guidance. ## Alternatives considered @@ -28,4 +28,4 @@ This supersedes only the initial-task identity decision in [Agent Teams](../feat Members pay for identity within the full runtime-context snapshot. Other context changes repeat it, while stable retained context adds no new message. Forks preserve inherited history and append current identity; actual provider cache hits remain best-effort. Existing Team authorization remains in tool execution. -Verification covers fresh and fork teammates, an independent fork from a teammate, optional-context suppression, compaction restoration, cold resume, and registration disposal. The recorded `team-targets` scenario pins Lead context; the keyless headless Team workflow pins teammate context through the shipped profile. +Verification covers fresh and fork children, an independent Session fork, optional-context suppression, compaction restoration, unavailable roots, cold resume, and registration disposal. Recorded ordinary subagent and Team workflows pin identity through their shipped compositions; delegated tasks retain final-result return guidance. diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md index d0e333a71e..f3ebb3e6b3 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 必需 runtime context 中的 Team 身份 +# Agent Note: 必需 runtime context 中的 subagent 身份 Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## Problem -嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 membership,同时保持共享系统指令一致。 +嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 subagent 树,同时保持共享系统指令一致。 ## Decision -`tool-agent-team` 通过 Agent 作用域的 `systemPrompt.context()` 注册 `team:identity`,并设置 `required: true`。提供方读取现有 Team membership,渲染成员角色、名字与寻址说明。关闭可选 context 不能移除它。固定协作策略保留在 system prompt;roster 和 task 状态仍通过工具查询。初始任务只包含传入的正文。 +`tool-subagent-control` 在普通 subagent 和 Team 组合中通过 `systemPrompt.context()` 提供必需的 `subagent:identity`。它从 `ctx.subagents.rootOf(agent)` 派生身份:根看到自身 Session id,后代看到自身、直接父级和根 id。根不可用时会明确报告。可选上下文抑制和 Team 模式都不会移除身份。只有根可用时,agent 才会分别根据对应标准工具的获准情况收到通信与发现指导。Team 任务策略保留在共享系统提示中;任务状态仍通过工具查询。 Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色变化会在继承历史之后追加当前完整 runtime-context 快照;保留的 context 未变化时不追加消息。不引入 Team 专属的保留状态、事件类型或日志迁移。已有身份提醒仍作为历史消息保留。 -本决策仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中的初始任务身份决策;其 roster、mailbox 和 task 决策仍然有效。 +这仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中将身份放入初始任务的决策。[任务板等待协调](2026-10-19-team-task-board-inbox.zh.md)使普通收件箱等待独立于 Team 专用模型指导。 ## Alternatives considered @@ -28,4 +28,4 @@ Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色 成员承担完整 runtime-context 快照中的身份 token 成本。其他 context 变化会重复身份,保留的 context 稳定时则不追加消息。Fork 保留继承历史并追加当前身份;提供方实际缓存命中仍为尽力而为。现有 Team 授权仍在工具执行时落实。 -验证覆盖 fresh 和 fork teammate、从 teammate 创建的独立 fork、关闭可选 context、压缩后恢复、冷恢复和注册释放。录制的 `team-targets` 场景固定 Lead context;无密钥 headless Team 工作流通过内置 profile 固定 teammate context。 +验证覆盖新建和 fork 子级、独立 Session fork、可选上下文抑制、压缩恢复、不可用根、冷恢复和注册释放。录制的普通 subagent 和 Team 工作流通过各自的分发组合固定身份行为;委派任务保留最终结果返回指导。 diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml new file mode 100644 index 0000000000..282acab92a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-15-agent-teams-as-subagents.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md +/agent-note-agent-teams-teammates-are-subagents: + en: 51e2fc51a6e1622a + zh: 7e09ea6292e5921d +/agent-note-agent-teams-teammates-are-subagents/problem: + en: ca2848e3bbc3a009 + zh: 0551e32ff6d187d0 +/agent-note-agent-teams-teammates-are-subagents/decision: + en: 2c29c42cc02a9a05 + zh: bc542e3a27cfd03d +/agent-note-agent-teams-teammates-are-subagents/alternatives-considered: + en: ef0cf648ff54784f + zh: 2a0b3745a54e7072 +/agent-note-agent-teams-teammates-are-subagents/consequences: + en: 3ff718671be076fa + zh: 38a367077ce95d52 +/agent-note-agent-teams-teammates-are-subagents/testing: + en: a0ec8546a783bfd9 + zh: 8920b46970e3a2de diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md new file mode 100644 index 0000000000..8cab7023e0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md @@ -0,0 +1,41 @@ +# Agent Note: Agent Teams teammates are subagents + +Status: implemented + +English | [中文](2026-10-15-agent-teams-as-subagents.zh.md) + +The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. + +## Problem + +Teammates already use the subagent runtime for creation, Session identity, catalog membership, capacity, cold resume, settlement, and interruption. A separate Team roster duplicates identity, provisioning recovery, and teardown. Team-scoped messaging and control tools shadow the standard subagent tools, forcing profiles to disable ordinary delegation and making fork tool schemas diverge from their parent. + +## Decision + +Use one subagent identity: the Session id. The existing `description` tool argument supplies the catalog label and display text; descriptions may repeat and never address an agent. Creation accepts no member name, reserves no names, and adds no catalog payload version. + +[Tree-wide communication](2026-10-16-tree-wide-agent-communication.md) authorizes an exact live sender within one root tree and preserves the actual sender’s id. Interruption retains ancestor authority and id addressing. + +Every root Agent and its descendants form the Team identified by the root Session id. Team creates no agents and contributes task tools and task policy. Ordinary subagent controls supply required identity, discovery, messaging, interruption, and inbox waiting. Parent and fork children receive identical tool schemas and shared policies. + +Task ownership uses `ownerId`; root reassignment accepts itself or any reachable continuable descendant. Historical roster records remain decode-only. Team persists task changes without ending inbox waits or starting idle turns; [inbox-only waiting](../simplification/2026-10-29-inbox-only-agent-wait.md) owns wake behavior. Historical `team/message/*` records remain readable but are never delivered or acknowledged. + +This supersedes the roster, provisioning, recovery, and messaging decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and the [Team direct inbox decision](../../archived/simplification/2026-09-26-team-direct-inbox.md). The task-board and shared-checkout decisions remain active. [Adjacent Agent messaging](2026-08-27-adjacent-agent-steer-messaging.md) includes sibling routing, while [required runtime-context identity](2026-10-13-team-identity-runtime-context.md) owns the role statement. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md) lists removed tools, methods, and configuration. + +## Alternatives considered + +**Separate Team authority and tools.** This retains a second roster and recovery lifecycle for Agents already owned by the subagent runtime, plus same-name tools and a private delivery adapter. The shared parent relation supplies the required authority. + +**Unique names as sibling-routing opt-in.** Names add validation, reservation, persistence, and ambiguous resolution beside existing Session ids. Descriptions already explain responsibilities; stable ids distinguish repeated descriptions without another identity system. + +**Pluggable address books.** A registry would support groups spanning unrelated parents but add another authorization input. No current consumer needs this; revisit when collaboration must cross parent trees. + +## Consequences + +Tree members discover and message one another through ordinary controls. Team retains task ownership, dependencies, and task policy. Subagent capacity and lifecycle apply; failed starts create no Team roster row. Existing durable task generations remain unchanged. + +Descendants at any depth share the board and can be task owners. Historical members grant no authority. Separate working directories remain explicit caller choices; cross-process task coordination is unsupported. + +## Testing + +Verification covers tree delivery and cold resume with actual sender attribution, task CAS and owner discovery, historical decoding, inbox wait isolation, and registration disposal. Recorded sessions cover Team policy and identity, plus ordinary subagent return guidance. diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md new file mode 100644 index 0000000000..8c113d288a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Agent Teams 队友是子代理 + +Status: implemented + +[English](2026-10-15-agent-teams-as-subagents.md) | 中文 + +邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。 + +## Problem + +队友已经使用子代理运行时完成创建、Session 身份、目录成员关系、容量、冷恢复、结算和中断。独立 Team 成员列表重复了身份、创建恢复和释放职责。Team 作用域内的消息及控制工具覆盖标准子代理工具,迫使 profile 禁用普通委派,并使分叉工具模式偏离父级。 + +## Decision + +统一使用子代理的 Session id 作为身份。现有 `description` 工具参数提供目录标签和展示文本;描述可以重复,不用于寻址代理。创建不接收成员名称、不预留名称,也不增加目录载荷版本。 + +[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)在同一根树内授权当前在线发送方,并保留实际发送方 id。中断保留祖先权限和 id 寻址。 + +每个根 Agent 及其后代组成以根 Session id 标识的 Team。Team 不创建 agent,提供任务工具和任务策略。普通 subagent 控制工具提供必需身份、发现、通信、中断和收件箱等待。父级和 fork 子级接收相同工具 schema 和共享策略。 + +任务归属使用 `ownerId`;根重新分配可指定自身或任意可达的可继续执行后代。历史名册记录仅供解码。Team 持久化任务变更,不结束收件箱等待,也不启动空闲轮次;[仅等待收件箱](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)负责唤醒行为。历史 `team/message/*` 记录仍可读取,但绝不再投递或确认。 + +本决策取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的成员列表、创建、恢复和消息决策,以及 [Team 直接收件箱决策](../../archived/simplification/2026-09-26-team-direct-inbox.md)。任务板和共享工作树决策仍有效。[相邻 Agent 消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)纳入同级路由,[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责角色声明。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md)列出移除的工具、方法和配置。 + +## Alternatives considered + +**独立 Team 权限和工具。** 这会为子代理运行时已拥有的 Agent 保留第二套成员列表与恢复生命周期,以及同名工具和私有投递适配器。共同父级关系已经提供所需权限。 + +**以唯一名称选择性启用同级路由。** 名称会在现有 Session id 之外增加校验、预留、持久化和歧义解析。描述已能说明职责;稳定 id 可以区分重复描述,无需另一套身份系统。 + +**可插拔地址簿。** 注册表可支持跨不同父级的组,但会增加另一种授权输入。当前没有消费者需要此能力;协作必须跨父级树时再考虑。 + +## Consequences + +树内成员通过普通控制工具发现和联系彼此。Team 保留任务归属、依赖和任务策略。容量和生命周期遵循 subagent 规则;启动失败不会创建 Team 名册行。现有持久化任务代次保持不变。 + +任意深度后代共享任务板,均可成为任务所有者。历史成员不授予权限。独立工作目录仍由调用者显式选择;不支持跨进程任务协调。 + +## Testing + +验证覆盖具有实际发送方归属的整树投递和冷恢复、任务 CAS 与所有者发现、历史解码、收件箱等待隔离和注册释放。录制会话覆盖 Team 策略和身份,以及普通 subagent 返回指导。 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml new file mode 100644 index 0000000000..ee52c30615 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-16-tree-wide-agent-communication.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md +/agent-note-communication-follows-the-subagent-tree: + en: 849d806b901e9f04 + zh: 649b2984f844d510 +/agent-note-communication-follows-the-subagent-tree/problem: + en: c583c3cdb894e417 + zh: 6edc0354b79f4f26 +/agent-note-communication-follows-the-subagent-tree/decision: + en: da35a75e069fa1e4 + zh: 7edf62ad66ab04ec +/agent-note-communication-follows-the-subagent-tree/alternatives-considered: + en: 470f9cf8c3e32af9 + zh: 73bbe29767c5d02a +/agent-note-communication-follows-the-subagent-tree/consequences: + en: 6d409833d762d473 + zh: 000cd93577ec6b34 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md new file mode 100644 index 0000000000..30dee6781a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md @@ -0,0 +1,29 @@ +# Agent Note: Communication follows the subagent tree + +Status: implemented + +English | [中文](2026-10-16-tree-wide-agent-communication.zh.md) + +## Problem + +A Team shares one task board across the root's entire subagent tree. Restricting discovery and messages to adjacent relatives forces deeper members to relay work through parents despite sharing tasks. + +## Decision + +`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Parent ids and depth remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. + +`send_message` accepts another root or continuable member of the same tree. The sender must be the exact live root or a resident continuable subagent. Cross-branch delivery verifies the target descriptor and creation catalogs, then restores only the target under the Team root. Final inbox admission rechecks the sender and root. Cancellation or rejection before acceptance releases the new target instance; accepted input follows normal Agent persistence. + +The existing Agent registry records runtime ownership by the Team root. Creation parent ids and catalogs preserve ancestry for discovery and interrupt authority. A member finishing or being released affects only its own instance; Team-root shutdown joins all startup and cleanup. This removes ancestor restoration, parent retention, and recursive disposal without adding a Team runtime registry. Saved target presets and permission events supply restoration configuration. + +This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Members use Session ids for addressing; ordinary Session forks lead independent Teams. + +## Alternatives considered + +**Relay through parents.** This preserves the narrower routing implementation but makes the creation hierarchy determine who can collaborate. Shared work requires direct communication across branches. + +**Separate Team directory and delivery.** A second member list and message path would duplicate the existing catalogs, inbox, and restoration lifecycle. Tree membership supplies the required communication scope without another registry. + +## Consequences + +Discovery output grows with the whole tree. Cold delivery requires one capacity slot and readable creation history. Intermediate creators may be offline. Completion notices reach only online creators, so work that requires descendant results must explicitly wait. Listing is a snapshot and does not promise that a later send succeeds. External and one-shot executions cannot receive follow-ups. diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md new file mode 100644 index 0000000000..58d075e9a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 通信范围由子代理树决定 + +Status: implemented + +[English](2026-10-16-tree-wide-agent-communication.md) | 中文 + +## Problem + +一个 Team 在根的整棵子代理树中共享任务板。把发现和消息限定在相邻成员之间,会迫使深层成员在共享任务的同时,仍通过父级转发工作。 + +## Decision + +`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。父级 id 和深度始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 + +`send_message` 接受同一棵树中的其他根或可继续成员。发送者必须是确切在线根或驻留的可继续子代理。跨分支投递验证目标描述符和创建目录后,仅在 Team 根下恢复目标。最终收件箱准入重新检查发送者与根。接受前的取消或拒绝释放新建目标实例;已接受输入遵循普通 Agent 持久化。 + +现有 Agent 注册表记录 Team 根对运行时实例的所有权。创建父级 id 和目录保留用于发现及中断授权的祖先关系。成员完成或释放只影响自身实例;关闭 Team 根会等待全部启动和清理。这删除了祖先恢复、父级保留和递归释放,无需增加 Team 运行时注册表。目标保存的 preset 和权限事件提供恢复配置。 + +本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。成员使用 Session id 寻址;普通 Session 分叉领导独立 Team。 + +## Alternatives considered + +**通过父级转发。** 这保留了更窄的路由实现,却让创建层级决定谁能协作。共享工作需要跨分支直接通信。 + +**单独的 Team 目录与投递。** 第二份成员列表和消息路径会重复现有目录、收件箱与恢复生命周期。树成员身份已经提供所需通信范围,无需另一个注册表。 + +## Consequences + +发现输出随整棵树增长。冷投递需要一个容量名额和可读的创建历史。中间创建者可以离线。完成通知仅投递给在线创建者,因此依赖后代结果的工作必须显式等待。列表是快照,不保证后续发送成功。外部执行和一次性执行不能接收后续消息。 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml index 954a507a27..998bf5a661 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.i18n.yaml @@ -9,8 +9,8 @@ en: 9f84c9bd85b6aace zh: 8ebb10962c219624 /agent-note-serve-session-list-rows-from-a-summary-key-set/decision: - en: 6e8f43d7a53c35fd - zh: a6e2bccc20474515 + en: aada3ca481120bff + zh: 1fa45a276a47e921 /agent-note-serve-session-list-rows-from-a-summary-key-set/alternatives-considered: en: ce8320f1032018ed zh: 4c5fad5e605bf524 diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md index 89e7b2c37d..6f66a06299 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.md @@ -13,10 +13,10 @@ A Session-list row carries every registered client view. Four of those views are The Session summary is every registered client view except the keys whose `SessionProjectionMap` entry carries the summary-exclusion mark. - [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) declares the type-only mark `SessionProjectionSummaryExcluded` over a unique symbol that is never exported and never present at runtime. `SessionProjectionViewValue` strips it distributively, so a nullable entry keeps its `null`; `SessionProjectionSummaryExcludedKey` tests the non-nullish part; `SessionProjectionSummaryValues` is every view value minus those keys. -- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), and `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. +- Each owning package wraps only its own map entry: `turnOutline` (session-turn-outline), `sessionStats` (session-stats), `todos` (tool-todo), `imageLimits` (session-controller's boot-constant attachment limits, which every Session shares), and `contextPressure` and `contextBreakdown` (token-meter). The todo entry is `SessionProjectionSummaryExcluded | null`, because an intersection with `null` would absorb the null member. - Registration carries the same decision as a plain boolean: `wire.summary` says whether a Session summary includes the unit, and omitting it means `true`. A key whose map entry carries the mark declares `false` so the runtime key set and the client view type agree. The mark itself has no runtime representation — `wire.view` and `viewSchema` are typed with `SessionProjectionViewValue`, which strips it, so the erase in `register` only widens the state type. - [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) exposes `summaryKeys(): ReadonlySet`, derived from the live registrations and rebuilt only when a new key registers or a key's last registrant disposes. `cachedSnapshot`, `viewCheckpoint`, and `snapshot` accept `ProjectionKeySelection` (`readonly Key[] | ReadonlySet`) and use a passed set as-is. -- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live and cold cached faces. `cachedPredecessorTitle` keeps its title-only read. +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) forwards that selection unchanged, and [`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) passes `summaryKeys()` to the live registry's `snapshot` and to the cold cache read. `cachedPredecessorTitle` keeps its title-only read. - Client types only: `SessionSummary.projectionValues` narrows to `Partial`, while `SessionProjectionSnapshot.values` keeps every view value. No wire field, function, or UI name changes, and an opened Session still receives every key through `session/follow`, the control baseline, and `session.projections`. Estimated effect: with logs whose outlines dominate the projection payload, the four keys are estimated at roughly two thirds of a Session-list response for a few hundred Sessions — the outline alone near 57%, the figures and todo list near 5% together, and the shared image limits near 5% — and at roughly 85% of the same response after transport compression, where the outline's unique text barely compresses while the remaining keys do. diff --git a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md index 75c753c345..d3212fe6ce 100644 --- a/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-18-session-list-summary-projections.zh.md @@ -13,10 +13,10 @@ Session 列表行携带每个已注册的客户端视图。其中四个视图从 Session 摘要就是每个已注册客户端视图,减去 `SessionProjectionMap` 条目带摘要排除标记的键。 - [`packages/session/session-projection/src/types.ts`](../../../../packages/session/session-projection/src/types.ts) 声明类型层标记 `SessionProjectionSummaryExcluded`,它基于一个从不导出、运行时从不存在的 unique symbol。`SessionProjectionViewValue` 以分发方式去掉标记,可空条目因此保留 `null`;`SessionProjectionSummaryExcludedKey` 取非空部分判断;`SessionProjectionSummaryValues` 是全部视图值减去这些键。 -- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo),以及 `imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 +- 每个 owning package 只包装自己的 map 条目:`turnOutline`(session-turn-outline)、`sessionStats`(session-stats)、`todos`(tool-todo)、`imageLimits`(session-controller 的启动常量附件上限,每个 Session 共享),以及 `contextPressure` 和 `contextBreakdown`(token-meter)。todo 条目写作 `SessionProjectionSummaryExcluded | null`,因为与 `null` 求交会吞掉 null 成员。 - 注册用普通布尔值承载同一决定:`wire.summary` 表示 Session 摘要是否包含该单元,省略即 `true`。map 条目带标记的 key 声明 `false`,让运行时键集合与客户端视图类型一致。标记本身没有运行时表示 —— `wire.view` 与 `viewSchema` 的签名已用 `SessionProjectionViewValue` 去标记,因此 `register` 里的擦除只是放宽状态类型。 - [`packages/session/session-projection/src/index.ts`](../../../../packages/session/session-projection/src/index.ts) 提供 `summaryKeys(): ReadonlySet`,从实时注册派生,只有新键注册或某键最后一个注册者注销时才重建。`cachedSnapshot`、`viewCheckpoint`、`snapshot` 接受 `ProjectionKeySelection`(`readonly Key[] | ReadonlySet`),传入 Set 时直接使用。 -- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给 live 与 cold 两个缓存读取面。`cachedPredecessorTitle` 保持只读 title。 +- [`packages/session/session-projection-cache/src/index.ts`](../../../../packages/session/session-projection-cache/src/index.ts) 原样转发该选择,[`packages/api/session-controller/src/list.ts`](../../../../packages/api/session-controller/src/list.ts) 把 `summaryKeys()` 传给实时注册表的 `snapshot` 与冷会话的缓存读取。`cachedPredecessorTitle` 保持只读 title。 - 客户端只有类型变化:`SessionSummary.projectionValues` 收窄为 `Partial`,而 `SessionProjectionSnapshot.values` 保留全部视图值。wire 字段、函数名与 UI 名称均不变;打开的 Session 仍通过 `session/follow`、控制基线与 `session.projections` 收到每个键。 估算效果:当 outline 在投影负载中占主导时,四个键估计占几百个 Session 时列表响应的约三分之二 —— outline 单项约 57%,统计与 todo 合计约 5%,共享的图片上限约 5% —— 占传输压缩后同一响应的约 85%,因为 outline 的唯一文本几乎无法压缩,而其余键可以。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml new file mode 100644 index 0000000000..aa751903bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-team-task-board-inbox.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md +/agent-note-team-task-boards-share-ordinary-subagent-waits: + en: 7f19734715f5d50f + zh: 95af09c9d855d1af +/agent-note-team-task-boards-share-ordinary-subagent-waits/problem: + en: 6fdc766a348d8659 + zh: 8a84748e147deed5 +/agent-note-team-task-boards-share-ordinary-subagent-waits/decision: + en: 154da8a8a5146c89 + zh: 76a7c921153d238d +/agent-note-team-task-boards-share-ordinary-subagent-waits/alternatives-considered: + en: eed6f6d4fa0ccd7b + zh: 61271057d6e5d019 +/agent-note-team-task-boards-share-ordinary-subagent-waits/consequences: + en: cc250eb21dbb45a1 + zh: 9d03d1eb53075fb6 diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md new file mode 100644 index 0000000000..818298684c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md @@ -0,0 +1,31 @@ +# Agent Note: Team task boards share ordinary subagent waits + +Status: implemented + +English | [中文](2026-10-19-team-task-board-inbox.zh.md) + +## Problem + +The subagent tree already supplies identity, discovery, routing, and lifecycle. A second Team roster can grant assignment authority from stale history and duplicates member navigation. Team-wide status waits observe unrelated activity and miss input already pending for the caller. Persisting task-board wakeups as messages adds unread state and message schemas even though the task journal already owns the state to inspect. + +## Decision + +The subagent tree is the Team. Ordinary controls own required identity, communication, discovery, interruption, and `wait_agent`, independently of the optional task board. Team tools own task policy, without adding it to ordinary subagent compositions. Team owns task storage, dependencies, and compare-and-set commands. Assignment accepts the root or any discovered continuable descendant. Historical roster records grant no authority; the Team panel shows tasks and ordinary navigation owns members. + +The wait-signal decision is superseded by [inbox-only Agent waits](../simplification/2026-10-29-inbox-only-agent-wait.md). Tree ownership, task durability, and historical non-authority remain unchanged. + +Task state remains in the existing journal. Ordinary tool results record what the waiting model observes. Historical roster, mailbox, and task generations remain readable and unchanged. This partially supersedes the roster and wait decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their task durability and single-identity rationale remain active; [required runtime identity](2026-10-13-team-identity-runtime-context.md) owns retention. + +## Alternatives considered + +**Keep Team membership and a separate wait tool.** This duplicates tree authority and wait lifecycle, allowing stale roster rows or unrelated activity to affect current work. + +**Persist task-board notices in every inbox.** The task journal already preserves every revision. Durable notices add message types, unread state, coalescing, and delivery-failure recovery solely to interrupt a current wait. + +**Poll Agent status or start every idle task owner.** Activity does not identify useful input, and task readiness does not authorize a new turn. Explicit messages continue idle work when needed. + +## Consequences + +Task completion, inbox availability, current waiting, and Agent lifetime remain separate. Task updates do not end waits; callers inspect current tasks when deciding what to do. The task journal retains committed revisions without storing redundant notifications in recipient Sessions. + +Verification distinguishes pending inbox input from task commits, isolates callers, excludes next-turn input and status changes, and covers wait cleanup and task persistence. Assignment coverage retains arbitrary depth and historical non-authority. diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md new file mode 100644 index 0000000000..3a88ce8d19 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Team 任务板复用普通 subagent 等待 + +Status: implemented + +[English](2026-10-19-team-task-board-inbox.md) | 中文 + +## 问题 + +subagent 树已经提供身份、发现、路由和生命周期。第二份 Team 名册可能根据过期历史授予分配权限,并重复成员导航。Team 范围状态等待会观察无关活动,却漏掉调用者已有的待处理输入。任务日志已经拥有待查询状态,将任务板唤醒持久化为消息仍会增加未读状态和消息 schema。 + +## 决策 + +subagent 树就是 Team。普通控制工具独立于可选任务板,负责必需身份、通信、发现、中断和 `wait_agent`。Team 工具负责任务策略,不向普通 subagent 组合添加该策略。Team 负责任务存储、依赖和比较并交换命令。分配接受根或任意已发现的可继续执行后代。历史名册不授予权限;Team 面板显示任务,普通导航负责成员。 + +等待信号决策已由[仅等待收件箱的 Agent 等待](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)取代。树归属、任务持久性和历史记录不授予权限的决策保持不变。 + +任务状态保留在现有日志中。普通工具结果记录等待中的模型所观察到的结果。历史名册、邮箱和任务代次保持可读且不变。这部分取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 和 [subagent 队友](2026-10-15-agent-teams-as-subagents.zh.md) 中的名册与等待决策。其任务持久性和单一身份理由继续生效;[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责保留规则。 + +## 考虑过的替代方案 + +**保留 Team 成员关系与独立等待工具。** 这会重复树权限和等待生命周期,让过期名册行或无关活动影响当前工作。 + +**将任务板通知持久化到每个收件箱。** 任务日志已保留每个修订。持久通知仅为打断当前等待,就增加消息类型、未读状态、合并和投递失败恢复。 + +**轮询 Agent 状态或启动每个空闲任务所有者。** 活动状态不能标识有用输入,任务就绪也不授权新轮次。需要时通过显式消息继续空闲成员的工作。 + +## 后果 + +任务完成、收件箱可用性、当前等待和 Agent 生命周期保持独立。任务更新不会结束等待;调用者在决定下一步工作时查询当前任务。任务日志保留已提交修订,不在接收者 Session 中存储重复通知。 + +验证区分已有收件箱输入和任务提交,隔离调用者,排除下一轮输入及状态变化,并覆盖等待清理和任务持久性。分配验证保留任意深度和历史记录无权限场景。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml new file mode 100644 index 0000000000..056ad6e589 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-20-session-team-mode.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-session-team-mode.md +/agent-note-team-availability-is-selected-per-session: + en: 5f36d81bd414ea67 + zh: 71883b0fe2afd1b6 +/agent-note-team-availability-is-selected-per-session/problem: + en: db7a81ab452fdd0f + zh: a14fdcdb8ff4cd47 +/agent-note-team-availability-is-selected-per-session/decision: + en: d1174db5157b91ee + zh: ceb6dc5a89a0a8d6 +/agent-note-team-availability-is-selected-per-session/alternatives-considered: + en: d50ed45e8f902281 + zh: c01d9be4b6e5df06 +/agent-note-team-availability-is-selected-per-session/consequences: + en: b56a6a1c8ad1aa7a + zh: 43bdd8a5bccce8bf diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md new file mode 100644 index 0000000000..63fb659556 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md @@ -0,0 +1,33 @@ +# Agent Note: Team availability is selected per Session + +Status: implemented + +English | [中文](2026-10-20-session-team-mode.zh.md) + +## Problem + +A plugin-level Team switch requires users to change the shared profile before choosing collaboration for one conversation. It can disable another Session’s capability and leaves a second control beside the Session selection. Ordinary tasks still need their existing tool catalog, and disabling collaboration must not discard task history or children. + +## Decision + +The base bundle loads the Team service and task tools, and the Web bundle loads the Team UI. The packages belong to the subagent and client product groups. A saved root-Session selection gates task tools and Team guidance throughout the tree, and the shared board in the root conversation; new Sessions default off. Ordinary subagent creation, communication, and navigation remain available. + +The model and reasoning menu owns the user’s selection. The root conversation’s Subagent panel displays members above shared tasks. Disabling Team keeps the journal and children; re-enabling restores the board. Historical task boards without a saved selection stay enabled. Profile loading removes the retired optional bundle selection, retaining row overrides. + +Headless deployments may set `agent-team.defaultEnabled` to save an initial selection for a new root during startup creation. Saved selections take precedence, and resumed Sessions retain their mode even when no selection exists. Ordinary forks initialize independently; subagents share their live root’s selection. Creation identifies subagents by their Session origin because activation ownership is registered only after creation listeners settle. Task access still requires the live runtime owner. + +This supersedes the explicit-composition choice in [Agent Teams](../feature/2026-08-05-agent-teams.md). Its durability, ownership, and lifecycle decisions remain active. [Optional bundles](../process/2026-09-15-shipped-optional-bundles.md) still governs other experimental features. + +## Alternatives considered + +**Keep both switches.** Capability installation remains a prerequisite that the conversation control cannot explain or resolve; users must manage shared profile state for a Session decision. + +**Apply the deployment default on resume or at read time.** Changing a profile would change an existing conversation’s tools without a saved selection. Persisting the default once at creation keeps subsequent capability decisions reconstructable from the Session log. + +**Hide the plugin card without loading Team.** New profiles would have no way to obtain the capability. Loading experimental packages in the default profile would violate product isolation; moving them to their owning product groups makes the delivered support explicit. + +## Consequences + +Default profiles carry the Team implementation while exposing no task tools until enabled. The removed package names require custom compositions to migrate; durable event names and task data remain unchanged. [The upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) owns those steps. + +Verification covers default startup, new-Session opt-in, independent roots, persisted selections, retained tasks, old profile migration, and absence of the plugin switch. Product-isolation checks retain an unrelated experimental plugin as their negative control. diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md new file mode 100644 index 0000000000..e5ef16a331 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 按 Session 选择 Team 能力 + +Status: implemented + +[English](2026-10-20-session-team-mode.md) | 中文 + +## Problem + +插件级 Team 开关要求用户先修改共享 profile,才能为一个会话选择协作。它可能关闭另一个 Session 的能力,并在会话选项之外留下第二个控件。普通任务仍需要原有工具目录,关闭协作也不能丢弃任务历史或子智能体。 + +## Decision + +基础 bundle 加载 Team 服务与任务工具,Web bundle 加载 Team UI。相关包归属 subagent 与 client 产品组。根 Session 保存的选择控制整棵树的任务工具与 Team 指引,以及根会话中的共享任务板;新 Session 默认关闭。普通 subagent 创建、通信与导航继续可用。 + +模型与推理等级菜单承载用户选择。根会话的 Subagent 面板在成员下方显示共享任务。关闭 Team 保留日志与子智能体;重新开启恢复任务板。没有保存选择的历史任务板保持开启。profile 加载时移除已停用的可选 bundle 选择,保留配置行覆盖。 + +无界面部署可设置 `agent-team.defaultEnabled`,在新根会话的启动创建阶段保存初始选择。已保存的选择优先,恢复的 Session 即使没有保存选择也保留原模式。普通分叉独立初始化;子智能体共享当前运行时根的选择。创建阶段通过 Session origin 识别子智能体,因为激活归属在创建监听器结束后才登记。任务访问仍要求当前运行时归属有效。 + +本决策替代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 中显式选择组合的部分;其持久性、归属与生命周期决策继续有效。[可选 bundle](../process/2026-09-15-shipped-optional-bundles.zh.md)仍约束其他实验性功能。 + +## Alternatives considered + +**保留两个开关。** 安装能力仍是会话控件无法解释或解决的前置条件;用户必须为 Session 决策管理共享 profile 状态。 + +**在恢复或读取时应用部署默认值。** 修改 profile 将在没有保存选择的情况下改变现有会话的工具。在创建时一次性持久化默认值,可让后续能力选择从 Session 日志重建。 + +**隐藏插件卡片但不加载 Team。** 新 profile 将无法获得该能力。默认 profile 加载实验性包会违反产品隔离;将相关包移入所属产品组可明确随产品提供的支持。 + +## Consequences + +默认 profile 包含 Team 实现,但开启前不暴露任务工具。自定义组合必须迁移已移除的包名;持久事件名称与任务数据保持不变。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)负责具体步骤。 + +验证覆盖默认启动、新 Session 手动开启、独立根、选择持久化、任务保留、旧 profile 迁移和插件开关移除。产品隔离检查使用另一个无关实验性插件作为负对照。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.i18n.yaml new file mode 100644 index 0000000000..bcc252082f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-20-workspace-tree-state-per-pane.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open: + en: 3768a6ac42af3a0b + zh: ba3c0826b2a4d287 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/problem: + en: 3a7f14dd68a3ff14 + zh: b21be07e8a7e6024 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision: + en: a15c7c13f167e5ac + zh: ff261e3705c47ff0 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-tree-is-drawn-inside-each-file-tab-and-its-state-is-kept-per-pane: + en: 432aff475f1dfd21 + zh: bbc058c1f967c067 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-rows-are-a-controlled-component-factory-over-flat-entries: + en: a06955933f9f7f0e + zh: ccda2bd27175d36b +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/the-plugin-that-mounts-the-tree-decides-where-a-file-lands: + en: 7cf8e3671900fe71 + zh: 952f69cba9c44c10 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/navigation-is-consumed-by-the-destination-tab: + en: e7b254df55d9768d + zh: d3303f20bb9ebdd6 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/decision/each-package-works-without-the-others: + en: dd160808a3ac77bc + zh: f63741f159717072 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/alternatives-considered: + en: 1c5501c71afc7748 + zh: 3a2b0ec2d7923a12 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/consequences: + en: 586ec09cfcf60b82 + zh: ef683318fbf68d71 +/agent-note-the-workspace-tree-is-per-pane-state-drawn-inside-each-file-tab-and-the-plugin-mounting-it-decides-where-files-open/verification: + en: eae354783022bed8 + zh: fe2563a3bb259d55 diff --git a/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md new file mode 100644 index 0000000000..3f1756978a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.md @@ -0,0 +1,74 @@ +# Agent Note: The workspace tree is per-pane state drawn inside each file tab, and the plugin mounting it decides where files open + +Status: implemented + +English | [中文](2026-10-20-workspace-tree-state-per-pane.zh.md) + +## Problem + +Reading several workspace files needs a tree beside their previews without losing expansion, loaded levels, width, scroll, or keyboard position on every tab switch. The dock mounts only the selected tab's body, so a component instance cannot own the state shared by a pane's file tabs. + +A floating panel holds one tab. A preview's file picker must replace that tab in its panel, while the files page must remain available and open content in the dock. A covering tree must close where content is navigated to, not where the initiating click happened. + +## Decision + +### The tree is drawn inside each file tab, and its state is kept per pane + +`ui-sidebar-right` supplies `useTabInfo().panel` with the containing pane's `id` and `isFloating`; moving a tab updates those fields without changing `tab.id`. It supplies no pane lifetime. `ui-sidebar-files` owns each pane's tree state, visibility, width, listing, directory watches, scroll, and last revealed file. The navigator and toggle are Session-scoped Component Factories; `ui-sidebar-documentpreview` renders them inside the tab's content row and header, with stable positions across loading states. The docking kit does not acquire a files-specific overlay or layout rule. + +A pane's records follow the tabs whose navigator last drew there, using each tab's `signal`. A replacement in the same pane retains the records. A moved tab uses its destination pane's records; a pane without records starts hidden at the default width. Tab-owned preview scroll is independent of that tree state. Records are released after their last associated tab closes or draws the navigator elsewhere. + +Directory watches follow visible drawings rather than retained records. Hidden trees hold no Host directory watch; showing them again refreshes the root and expanded directories while retaining rows. A same-pane tab switch keeps the listing and watches across the replacement drawing. This extends the [directory watch lifetimes](../feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md) from one tab to the pane's drawn tree. + +The files face owns ancestor reveal, scrolling, row measurement, and focus across drawings, so replacing a drawing does not discard an ongoing interaction. [The navigator reference](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) owns layout, workspace-command, and keyboard rules. + +### The rows are a controlled Component Factory over flat entries + +The root-scoped `sidebar.files.tree` Factory keeps reusable rows, its local `accessory` slot, and localized copy with the files plugin. Callers own data access, expansion, opening, and reveal. The workspace navigator adapts its pane's directory states to these inputs. + +### The plugin that mounts the tree decides where a file lands + +`FileNavigatorInput.onOpen(address)` is the containing plugin's callback. The navigator constructs the file address but neither chooses placement nor closes the initiating tree after the call. Its `page` input controls layout only. A callback keeps the same navigator usable for the files page and document previews without teaching its rows either host's opening policy. + +The [preview and files-page callbacks](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) express different lifetimes: the preview gives way to new content, while the files page remains a directory browser. [Right Sidebar placement](../../../../packages/client/ui-sidebar-right/README.md#ctxsidebarright) owns replacement and reveal; the navigator does not duplicate those policies. + +### Navigation is consumed by the destination tab + +The files face follows the existing navigation observable only for tabs attached to its navigator. It retains each tab's consumed revision independently of the drawing and releases that subscription on tab closure or plugin disposal. It does not require a layout inventory, navigation-origin field, or an opening result from the right Sidebar. + +The face waits for the destination drawing and measures its current row, so the previous tab's width cannot decide whether content is covered. It consumes navigation even while hidden to prevent a later manual show from replaying an old close. [The navigator reference](../../../../packages/client/ui-sidebar-files/README.md#the-navigator) defines the navigation sources and revision exclusions. + +### Each package works without the others + +The packages compose through the right Sidebar's tab information, the files Factories, and the tab-type registry. The preview imports only the files Factory declarations; files has no preview dependency. Without a registered viewer, the files page still lists the workspace and disables file rows it cannot open. Without the files plugin, the unregistered Factory positions render nothing and the preview keeps its own header, body, and file-handoff slots. + +## Alternatives considered + +**A static tree package or a component in `ui-primitives`.** The [package-shape rule](../../../../docs/defensive-patterns/package-surface.md) excludes new static libraries. A primitive would also require each caller to supply the generic tree labels. The Factory keeps that copy and registration with files, at the cost of requiring that plugin to draw rows. + +**A tree that owns directory reads or requires a complete path list.** A workspace is listed one expanded directory at a time. The tree therefore accepts partial entries and per-directory loading, failure, and truncation state. Owning reads inside each drawing would split the state and watch lifetimes that must survive tab switches. + +**A pane lifetime signal.** Floating and docking can replace the pane while retaining the tab. A pane signal would end tree state too early and invite attaching tab-owned resources to the wrong lifetime; transferring the signal would make it outlive the pane id used as the state key. The tabs' existing signals provide the required release condition. + +**A pane-level overlay that stays mounted.** It requires a layer above docked and floating tab hosts and changes to layout, focus, measurement, and drop targets to avoid intercepting input, including floating resize handles. Drawing inside tabs keeps these concerns in their existing owners. + +**Keep visited bodies mounted or copy per-tab trees.** Retaining every body still creates independent trees and watches for tabs sharing a pane and retains each preview's rendered content. Copying state cannot transfer watches and needs a conflict rule when tabs move between panes. + +**Keep watches for a pane's whole lifetime or release by inspecting its layout.** The main docked pane rarely disappears, so lifetime-bound watches remain after hiding. Inspecting tab kinds in the layout couples files to every navigator consumer and still does not express whether the tree is drawn. + +**A focus request bit consumed by a component effect.** A request stored on the initiating pane can survive a reveal elsewhere and affect a later drawing; a hidden consumer has no element to focus. The face ties focus to its actual drawings instead. + +**Carry a dragged tab's tree preference into its new pane.** This needs another transfer policy and conflicts with the state already owned by the destination pane. A new pane starts with the same defaults as every other new pane. + +## Consequences + +Tab remounts do not own or reset the pane's tree. Moving a tab selects the destination's state rather than copying the source's; hiding releases directory watches without deleting expansion and scroll. Navigation consumption is per tab, while display preferences remain per pane, so an open in another pane does not close the source tree. + +The files page retains directory reload and directory-action controls; a preview retains its file controls and handoff. Opening policy remains with those hosts, not the reusable rows. + +## Verification + +- `packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx`, `service.client.spec.ts`, and `seat.client.spec.tsx` cover live pane information, replacement, reveal, and framework tab hooks. +- `packages/client/ui-sidebar-files/tests/` covers per-pane state, navigation consumption, drawing lifetimes, workspace roots, and file opening callbacks. +- `packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx`, `file-tree-interactions.client.spec.tsx`, and `file-tree-factory.client.spec.tsx` cover generic rows, independent accessories, and real Factory composition with locale and fallback. +- `apps/web/tests/file-browser.e2e.ts` and `apps/web/tests/file-browser-composition.e2e.ts` own browser evidence; the latter disables each of `ui-sidebar-documentpreview` and `ui-sidebar-files` in the shipped Web composition. diff --git a/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.zh.md b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.zh.md new file mode 100644 index 0000000000..e3580c7fe1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-workspace-tree-state-per-pane.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 工作区目录树是按分栏保存的状态,在每个文件 tab 内绘制;挂载它的插件决定文件在哪里打开 + +Status: implemented + +[English](2026-10-20-workspace-tree-state-per-pane.md) | 中文 + +## 问题 + +连续阅读多个工作区文件需要预览旁的目录树在每次切换 tab 后保留展开状态、已加载的层、宽度、滚动位置与键盘位置。停靠布局只挂载选中 tab 的正文,因此组件实例不能拥有分栏内各文件 tab 共用的状态。 + +一个浮窗只容纳一个 tab。预览的文件选择器必须在浮窗内替换该 tab,文件页则必须保持可用,并在停靠区打开内容。遮盖正文的目录树应在内容被导航到的地方关闭,而不是在发起点击的地方关闭。 + +## 决定 + +### 目录树在每个文件 tab 内绘制,其状态按分栏保存 + +`ui-sidebar-right` 通过 `useTabInfo().panel` 提供所在分栏的 `id` 与 `isFloating`;移动 tab 时更新这些字段,`tab.id` 保持不变。它不提供分栏生命周期。`ui-sidebar-files` 拥有各分栏的目录树状态、可见性、宽度、列举、目录监听、滚动与上次定位的文件。导航栏和开关是 Session 作用域的 Component Factory;`ui-sidebar-documentpreview` 在 tab 的内容行与头部渲染它们,加载状态变化时保持位置稳定。停靠套件不增加 files 专属的覆盖层或布局规则。 + +分栏记录跟随最近在该分栏绘制导航栏的 tab,以各 tab 的 `signal` 为依据。同一分栏内的替换保留记录。移走的 tab 使用目标分栏的记录;没有记录的分栏默认隐藏目录树、使用默认宽度。tab 拥有的预览滚动状态独立于目录树状态。最后一个关联 tab 关闭或在别处绘制导航栏后,记录被释放。 + +目录监听跟随可见的绘制,而不是保留的记录。隐藏的目录树不持有 Host 目录监听;再次显示时保留行并刷新根目录与已展开目录。同一分栏内切换 tab 时,替代绘制之间保留目录列表与监听。这把[目录监听生命周期](../feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md)从单个 tab 扩展为分栏中已绘制的目录树。 + +files face 负责跨绘制的祖先定位、滚动、行宽测量与焦点,因此替换绘制不会丢弃进行中的交互。[导航栏参考](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义布局、工作区命令与键盘规则。 + +### 各行是以扁平条目为输入的受控 Component Factory + +root 作用域的 `sidebar.files.tree` Factory 将可复用的行、局部 `accessory` slot 和本地化文案留在 files 插件。调用方负责数据访问、展开、打开与定位。工作区导航栏把分栏的目录状态转换为这些输入。 + +### 挂载目录树的插件决定文件落在哪里 + +`FileNavigatorInput.onOpen(address)` 是容纳导航栏的插件提供的回调。导航栏构造文件地址,但不选择落位,也不在调用后关闭发起侧目录树。`page` 输入只控制布局。回调使同一导航栏能同时用于文件页与文档预览,不必让各行知道这两种宿主的打开策略。 + +[预览与文件页的回调](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)表达不同的生命周期:预览让位给新内容,文件页则保持为目录浏览器。[右侧 Sidebar 落位](../../../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright)负责替换与揭示,导航栏不重复这些策略。 + +### 导航由目标 tab 消费 + +files face 仅跟随已关联到其导航栏的 tab 的现有导航可观察值。它独立于绘制保留各 tab 已消费的 revision,并在 tab 关闭或插件卸载时释放订阅。它不需要右侧 Sidebar 提供布局清单、导航来源字段或打开结果。 + +face 等待目标绘制并测量其当前行宽,因此上一个 tab 的宽度不能决定内容是否被覆盖。隐藏期间也消费导航,防止之后手动显示时重放旧的关闭操作。[导航栏参考](../../../../packages/client/ui-sidebar-files/README.zh.md#the-navigator)定义导航来源与 revision 排除规则。 + +### 每个包都可以单独工作 + +各包通过右侧 Sidebar 的 tab 信息、files Factory 和 tab 类型注册表组合。预览仅导入 files Factory 的声明;files 不依赖预览。没有已注册查看器时,文件页仍列出工作区,并禁用无法打开的文件行。没有 files 插件时,未注册的 Factory 位置不绘制内容,预览保留自己的头部、正文和文件交接 slot。 + +## 考虑过的替代方案 + +**静态目录树包或 `ui-primitives` 中的组件。** [包形态规则](../../../../docs/defensive-patterns/package-surface.zh.md)排除新增静态库。primitive 还要求各调用方传入通用目录树文案。Factory 使文案与注册留在 files,代价是绘制各行必须加载该插件。 + +**由目录树读取目录,或要求完整路径列表。** 工作区逐个展开的目录列出。因此目录树接收不完整条目,以及各目录的加载、失败与截断状态。在每次绘制中拥有读取会拆散必须跨 tab 切换保留的状态和监听生命周期。 + +**分栏生命周期信号。** 浮出和停靠可能替换分栏,但保留 tab。分栏信号会过早结束目录树状态,还会诱使调用方把 tab 资源绑到错误的生命周期;转移信号则让它比用作状态键的 pane id 活得更久。tab 已有的信号提供了所需的释放条件。 + +**始终挂载的分栏级覆盖层。** 它需要在停靠与浮动 tab 宿主之上增加一层,并修改布局、焦点、测量与拖放目标,避免拦截包括浮窗缩放手柄在内的输入。在 tab 内绘制则让这些职责留在原有所有者处。 + +**保留已访问正文或复制逐 tab 的目录树。** 保留所有正文仍会为同一分栏的不同 tab 创建独立目录树与监听,并保留各预览的已渲染内容。复制状态无法转移监听,还需要在 tab 跨分栏移动时决定冲突如何处理。 + +**让监听与分栏同寿,或检查布局后释放。** 主停靠分栏很少消失,因此按其生命周期持有的监听在隐藏后仍存在。检查布局中的 tab 类型会让 files 与每个导航栏消费方耦合,仍不能表达目录树是否正在绘制。 + +**由组件 effect 消费的聚焦请求位。** 保存在发起分栏的请求可能在揭示别处后留存,影响之后的绘制;隐藏的消费方也没有可聚焦元素。face 则把焦点与实际绘制绑定。 + +**把被拖动 tab 的目录树偏好带到新分栏。** 这需要另一套转移策略,并与目标分栏已经拥有的状态冲突。新分栏使用与其他新分栏相同的默认值。 + +## 后果 + +tab 重新挂载不拥有也不重置分栏目录树。移动 tab 时选择目标状态,不复制源状态;隐藏释放目录监听,但不删除展开与滚动状态。导航消费按 tab 保存,显示偏好仍按分栏保存,因此在另一分栏打开内容不会关闭源目录树。 + +文件页保留目录重新读取与目录操作控件;预览保留文件控件与交接操作。打开策略由这些宿主拥有,不属于可复用的行。 + +## 验证 + +- `packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx`、`service.client.spec.ts` 与 `seat.client.spec.tsx` 覆盖实时分栏信息、原位替换、揭示与框架 tab hook。 +- `packages/client/ui-sidebar-files/tests/` 覆盖逐分栏状态、导航消费、绘制生命周期、工作区根目录与文件打开回调。 +- `packages/client/ui-sidebar-files/tests/file-tree.client.spec.tsx`、`file-tree-interactions.client.spec.tsx` 与 `file-tree-factory.client.spec.tsx` 覆盖通用行、独立附件,以及带 locale 和 fallback 的真实 Factory 组合。 +- `apps/web/tests/file-browser.e2e.ts` 与 `apps/web/tests/file-browser-composition.e2e.ts` 负责浏览器验证;后者在随包发布的 Web 组合中分别禁用 `ui-sidebar-documentpreview` 与 `ui-sidebar-files`。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml new file mode 100644 index 0000000000..458cbcc733 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-context-menu-contributions.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md +/agent-note-context-menu-contributions: + en: 380bed76b1989558 + zh: ecbb5c15933431f6 +/agent-note-context-menu-contributions/problem: + en: baf7f6e331fd9f03 + zh: 8a45faaad34e6b0c +/agent-note-context-menu-contributions/decision: + en: f04341778de57140 + zh: 12147060ea28feb1 +/agent-note-context-menu-contributions/alternatives-considered: + en: 1fc419cf3a0642d3 + zh: f774e64679bed2ab +/agent-note-context-menu-contributions/consequences: + en: 7a55e405714d598f + zh: d7ff74ace672bf22 diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md new file mode 100644 index 0000000000..da02632cf2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md @@ -0,0 +1,29 @@ +# Agent Note: Context menu contributions + +Status: implemented + +English | [中文](2026-10-21-context-menu-contributions.zh.md) + +## Problem + +Session and tab menus already accept React Slot entries. Reusing only those entries would require external plugins to supply React components and would leave action discovery, target capture, and cancellation coupled to each trigger. + +## Decision + +[Context menus](../../../../packages/client/ui-context-menu/README.md) own typed target families, contribution resolution, and callback lifetime. Feature plugins register actions through the same service available to external Client plugins. Plain presentation fields let a renderer consume resolved actions without loading contributed React components. Existing Session and tab Slots remain in feature-owned adapters, preserving those extension positions. + +[File actions](../../../../packages/client/ui-file-actions/README.md) own original-content transfer separately from menus and image components. Saving retains original encoding; clipboard interoperability may require PNG conversion. A browser download supplies no local path, so reveal requires a verified Host file or the destination returned by a native save. + +The menu plugin contributes `useContextMenu` through the existing Slot root binding. Its ordinary `getTriggerProps` factory centralizes DOM gestures while business owners still provide captured targets. Plain children receive props or existing delegates; a separate React Context or utility package would duplicate the established lifetime and injection mechanism. + +## Alternatives considered + +**React Slots alone.** They preserve arbitrary visual content but cannot supply a presentation-independent action model. They remain available for existing component contributions. + +**A central switch over built-in target kinds.** It would make each new plugin-owned target require changes to the menu implementation. Declaration merging and registered providers instead keep target-specific behavior with its owner. + +**Native menus first.** It would duplicate the existing keyboard, placement, and dismissal implementation while excluding component-only legacy entries. The shared Web renderer serves Desktop too; another renderer can consume the plain action view independently. + +## Consequences + +An opening captures its target and contribution set. Dismissal cancels metadata work; unloading withdraws that provider's actions. Selected actions own execution and must recheck mutable resources. Legacy React entries are absent from plain-data resolution, so native rendering cannot assume complete parity. These lifetimes are covered by registry tests; browser scenarios cover unchanged selection, original bytes, and nested-overlay dismissal. diff --git a/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md new file mode 100644 index 0000000000..cb0b3c5f3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 右键菜单贡献 + +Status: implemented + +[English](2026-10-21-context-menu-contributions.md) | 中文 + +## 问题 + +Session 和标签页菜单已经支持 React Slot 条目。仅复用这些条目会要求外部插件提供 React 组件,并使动作发现、目标捕获和取消逻辑继续耦合到各个触发位置。 + +## 决策 + +[右键菜单](../../../../packages/client/ui-context-menu/README.zh.md)负责类型化目标类别、贡献解析和回调生命周期。功能插件通过与外部 Client 插件相同的服务注册动作。纯数据展示字段让渲染器无需加载贡献方的 React 组件即可消费已解析动作。现有 Session 和标签页 Slot 保留在各功能所属的适配器中,从而保留这些扩展位置。 + +[文件动作](../../../../packages/client/ui-file-actions/README.zh.md)独立于菜单和图片组件负责原始内容传输。保存保留原始编码;剪贴板互操作可能需要转换为 PNG。浏览器下载不提供本机路径,因此在文件夹显示要求已验证的 Host 文件,或原生保存返回的目标路径。 + +菜单插件通过现有 Slot root 绑定贡献 `useContextMenu`。普通函数 `getTriggerProps` 统一处理 DOM 手势,业务所有者继续提供捕获的目标。普通子组件通过 props 或现有 delegate 接收能力;独立 React Context 或工具包会重复已有生命周期与注入机制。 + +## 考虑过的替代方案 + +**仅使用 React Slot。** 它们支持任意视觉内容,但无法提供独立于展示方式的动作模型。现有组件贡献仍可使用它们。 + +**在中心位置按内置目标类型分支。** 这种方式会使每个新增的插件目标都要求修改菜单实现。声明合并与注册式提供方让目标特有行为保留在其所属插件。 + +**优先实现原生菜单。** 这会重复已有的键盘、定位和关闭实现,同时无法容纳仅提供组件的旧条目。Desktop 同样使用共享 Web 渲染器;其他渲染器可以独立消费纯数据动作视图。 + +## 影响 + +打开菜单时捕获目标和贡献集合。关闭取消元数据任务;插件卸载撤回该提供方的动作。已选动作自行负责执行,并须重新检查可变资源。旧 React 条目不参与纯数据解析,因此原生渲染不能假定功能完全一致。注册表测试覆盖这些生命周期;浏览器场景覆盖选择不变、原始字节以及嵌套浮层关闭。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.i18n.yaml new file mode 100644 index 0000000000..23586315cb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-schedule-rule-storage.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.md +/agent-note-schedule-rule-storage-and-shared-recurrence: + en: 34817514a06a4351 + zh: a9e33a1c4d5845d1 +/agent-note-schedule-rule-storage-and-shared-recurrence/problem: + en: 22eed4a004cbb1c5 + zh: dc7b6f8ed9c074cf +/agent-note-schedule-rule-storage-and-shared-recurrence/decision: + en: 50bfa3e81af7c2b0 + zh: e9bd5fae6e611030 +/agent-note-schedule-rule-storage-and-shared-recurrence/alternatives-considered: + en: fbfcd35c35fb637a + zh: df9dd646e6d18e3c +/agent-note-schedule-rule-storage-and-shared-recurrence/consequences: + en: 61213641597c18bb + zh: 393a79013e311311 diff --git a/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.md b/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.md new file mode 100644 index 0000000000..2c62fcf258 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.md @@ -0,0 +1,25 @@ +# Agent Note: Schedule rule storage and shared recurrence + +Status: implemented + +English | [中文](2026-10-21-schedule-rule-storage.zh.md) + +## Problem + +Host tasks already use the versioned `schedule` domain. A second domain, importer, and placeholder RRULE duplicate timing and storage authority. Cron and RRULE should be supported timing types rather than separate generations of the task API. + +## Decision + +Use `schedule` version 2 with `compatibleVersions: [1]`, retaining its backend routing, envelope, and whole-unit layout. Extend the existing accepted-version policy to authoritative JSON and SQLite units; reads leave storage unchanged, and the next write commits the current stamp with its data. Add `rrule` to the six existing record kinds and evaluate every kind normally. Remove the standalone store importer, import marker, placeholder RRULE, and parallel Session-bound management API. Move reusable Cron and RRULE parsing and bounded occurrence arithmetic into `dsh-util-time`; Schedule owns records, targets, delivery admission, history, and errors. + +An explicit target wins. Without one, read an existing `sessionId` as a Session target; otherwise reserve a stable `new_once` target. Optional new metadata receives read defaults without rewriting storage. Every successful edit writes the current row format without `sessionId`. Custom timing accepts either RRULE or a five-field Cron expression with an explicit zone; UI labels name neither syntax. Model guidance continues to teach RRULE. + +This partially supersedes [standalone schedules](2026-09-17-standalone-schedules.md): its target, execution, notification, and Host-wide management decisions remain. It restores the domain and layout in [Host schedule storage](2026-09-16-host-schedule-storage.md), while replacing that note's Session-bound management API. Both notes remain active and link to this decision. + +## Alternatives considered + +Changing the domain or layout would require routing and copy logic without changing the feature. Converting Cron into RRULE can change day-of-month/day-of-week and time-zone semantics. A generic value migration engine is deferred until an incompatible storage version requires one; the existing domain envelope already identifies the version. + +## Consequences + +Missing creation metadata cannot recover historical timestamps. A default target must retain its reserved Session identity across reads and retries. Old receipts need their original destination retained before retargeting. A current unit stamp can contain older untouched rows, so future compatible readers must retain those defaults until a complete rewrite allows removal. Old binaries reject a newer stamp. Session inbox persistence and task acknowledgment remain separate commits, so delivery remains at least once. diff --git a/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.zh.md b/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.zh.md new file mode 100644 index 0000000000..a9d68c3e56 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 调度规则存储与共享重复时间计算 + +Status: implemented + +[English](2026-10-21-schedule-rule-storage.md) | 中文 + +## 问题 + +Host 任务已经使用带版本的 `schedule` 域。第二个域、导入器和占位 RRULE 会重复定义时间与存储的权威来源。Cron 和 RRULE 应当是并列的时间规则类型,而非两代任务 API。 + +## 决策 + +使用 `schedule` 版本 2 和 `compatibleVersions: [1]`,保留后端路由、封套和整单元布局。将现有接受版本策略扩展到权威 JSON 和 SQLite 单元;读取保持存储不变,下一次写入将当前版本戳与数据一同提交。在现有六种记录类型旁添加 `rrule`,所有类型均正常求值。移除独立存储的导入器、导入标记、占位 RRULE 和并行的 Session 绑定管理 API。将可复用的 Cron、RRULE 解析及有界时间计算移至 `dsh-util-time`;Schedule 负责记录、目标、投递准入、历史和领域错误。 + +显式目标优先。缺少目标时,将已有 `sessionId` 读取为 Session 目标;否则保留稳定的 `new_once` 目标。新增可选元数据使用读取默认值,不重写存储。每次成功编辑都写入不含 `sessionId` 的当前行格式。自定义时间同时接受 RRULE 和带显式时区的五字段 Cron 表达式;界面标签不提示任何语法名称。模型指引继续推荐 RRULE。 + +本决策部分替代[独立定时任务](2026-09-17-standalone-schedules.zh.md),保留其目标、执行、通知和宿主级管理决策。它恢复[宿主任务存储](2026-09-16-host-schedule-storage.zh.md)的领域与布局,并替代按 Session 绑定的管理 API。两个 note 保持活动并链接到本决策。 + +## 考虑过的替代方案 + +更换域或布局需要增加路由和复制逻辑,却不改变功能。将 Cron 转换为 RRULE 可能改变月内日期、星期和时区语义。通用值迁移引擎留待不兼容存储版本确有需要时再引入;现有域封套已经标识版本。 + +## 影响 + +缺失的创建元数据无法恢复历史时间戳。默认目标必须在读取和重试间保持预留 Session 标识。重新指定目标前,旧回执需要保留原始投递目标。当前单元版本戳可包含未改动的旧行,因此未来兼容读取器必须保留这些默认规则,直至完整重写后才能移除。旧程序拒绝更新的版本戳。Session 收件箱持久化与任务确认仍是两次独立提交,因此投递仍然至少一次。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml index 9ae0452069..575a8026c7 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.i18n.yaml @@ -12,8 +12,8 @@ en: 45c63df5dac389eb zh: d72a5bcb94de22f1 /agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/reads-and-subscriptions: - en: 38df4f3c4fba2cc9 - zh: a34f3376faf26f0c + en: a2439f02cb9654f9 + zh: 9de786792cba6a1d /agent-note-session-snapshots-passive-subscriptions-and-web-aggregation/decision/follow-ordering-and-ownership: en: 352ff87a3e9cd110 zh: 4ac6366cecfc08a3 diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md index b5e1112a18..98d632eda8 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.md @@ -18,7 +18,7 @@ History reads return chronological records, `hasMore`, and an inclusive cursor. `usingObservation({ sessionId, projection }, signal, callback?)` acquires the existing disposable SessionObservation. A callback borrows it until the callback settles; without a callback the caller owns disposal. Ordinary history, state, and Wait output reads do not request projections. Follow, exact projection reads, and subagent history descriptor validation request them. No read activates an Agent implicitly. -`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; projection updates replace individual keys and may share a sequence number. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations. +`observeEvent` and `observeProjections` install listeners without acquiring an Observation. Their initial `ready` confirms subscription, not a snapshot cursor, existence, parent-child validity, or Agent readiness. History establishes continuity from its first received event; each projection change frame covers one committed event and replaces only the keys it lists. `observeState` instead begins with a complete state read and coalesces later Session and Agent invalidations. History, history observation, and Follow accept Address requests; projection and state operations accept Session IDs. All internal readers and listeners use Session IDs. History and Follow validate Address against their opening Observation at the Controller layer, including the existing subagent descriptor rules. Pure history observation does not acquire a read for entity validation. Gateway resource authorization remains independent: the registered Session resolver extracts the target ID from the request before access is decided. diff --git a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md index 773ab039b3..fa9d53c2d8 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-21-session-history-stream-and-snapshot.zh.md @@ -18,7 +18,7 @@ Status: implemented `usingObservation({ sessionId, projection }, signal, callback?)` 获取已有的可释放 SessionObservation。有回调时租约借给回调直到其结束;无回调时由调用方释放。普通历史、状态及 Wait 输出读取不请求投影。Follow、精确投影读取和子会话历史 descriptor 校验请求投影。读取不会隐式激活 Agent。 -`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;投影更新替换单个键,多个更新可以共享序号。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。 +`observeEvent` 和 `observeProjections` 安装监听而不获取 Observation。首个 `ready` 确认订阅生效,不代表快照游标、实体存在、父子关系有效或 Agent 就绪。历史从首个收到的事件建立连续性;每个投影变更帧对应一个已提交事件,只替换帧内列出的键。`observeState` 则从完整状态读取开始,并合并后续 Session 和 Agent 失效通知。 历史、历史观察和 Follow 接收 Address 请求;投影和状态操作接收 Session ID。所有内部读取器和监听器都使用 Session ID。History 和 Follow 在 Controller 层以开场 Observation 校验 Address,包括已有的子会话 descriptor 规则。纯历史观察不为实体校验获取读取。Gateway 资源授权与之独立:注册的 Session 解析器在访问判定前从请求提取目标 ID。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml new file mode 100644 index 0000000000..82eae5a35d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-22-subagent-target-user-controls.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md +/agent-note-user-controls-address-the-target-subagent: + en: d7dcf54ddb311881 + zh: 5f388447737c5414 +/agent-note-user-controls-address-the-target-subagent/problem: + en: 962c75771235903c + zh: 203b651c0830310e +/agent-note-user-controls-address-the-target-subagent/decision: + en: 13a2617e28445744 + zh: 75d193ef7d8c7804 +/agent-note-user-controls-address-the-target-subagent/alternatives-considered: + en: 447adb24280bf2bb + zh: 08d95a93b14aa5c7 +/agent-note-user-controls-address-the-target-subagent/consequences: + en: 98e784f55775247d + zh: 1c62d2ebc71b658f diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md new file mode 100644 index 0000000000..064dbb1e17 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md @@ -0,0 +1,33 @@ +# Agent Note: User controls address the target subagent + +Status: implemented + +English | [中文](2026-10-22-subagent-target-user-controls.zh.md) + +## Problem + +A browser knows the subagent it is displaying, but requiring its direct creator and a continuable marker makes every control caller repeat history metadata. A caller-supplied parent id checks address consistency without establishing the caller's identity or permission. Calls that resolve only the global access resource also cannot express a grant on one target Session. + +## Decision + +Browser prompts and `interruptByUser` address only the target child Session. Each Remote method explicitly declares that Session as its access resource; the Gateway checks the invocation's principal before attachment admission, restoration, or cancellation. A global or parent Session grant does not imply a child Session grant. Policies decide whether to grant both actions, either action, or inherited access. Same-process user interruption trusts its caller and does not accept a wire-supplied principal. + +Prompt delivery derives the live root from the resident Activation or verified persisted creation edges. Only the cold target resumes; its creator can remain offline. Shared delivery retains exact live root checks, shutdown admission, cancellation, capacity, and final inbox acceptance. A root Session is not a target of the browser subagent prompt endpoint. User interruption addresses a resident Activation without persistence reads or restoration; an absent target remains a no-op, and interruption preserves parked input. + +Model messages retain the exact live sender and same-tree rule from [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). Model interruption retains its stricter ancestor rule. Creation parents still determine catalog edges, history-address consistency, ancestry, and completion recipients; they are not browser control credentials. + +This supersedes the direct-parent addressing requirement for human continuation in [continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). That record retains its inbox and durable continuation rationale; tree-wide communication owns its superseded model adjacency and runtime ownership restrictions. No Session format changes. + +## Alternatives considered + +**Keep the parent check as authorization.** The client supplies the parent id and can obtain it from history. Matching that id does not authorize the principal, and requiring it couples controls to discovery metadata. + +**Require a root id from the browser.** The server already derives the runtime owner from the target. Another supplied id adds a consistency check without granting permission; Agent senders still need an independently resolved root to enforce same-tree messaging. + +**Unify user and Agent interruption permissions.** A user with a target grant can stop that target, while an Agent may stop only its descendants. Same-tree collaboration does not grant members control over each other's execution. + +## Consequences + +Remote callers update their prompt fields, interrupt endpoint, and non-owner policy actions and resources; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md) owns the migration. History URLs retain parent ids for consistency checks. Cold host prompts may read creation ancestry twice, once to find the live root and again during restoration; no verified-ancestry cache is retained across asynchronous work. + +Gateway tests cover target grants, global-only and parent-only refusal, separate send and interrupt actions, owner access, and refusal before side effects. Continuation tests cover nested and cold targets with online or offline creators, unavailable roots, and rejection of a root as a subagent target. The Web interrupt scenario exercises the renamed endpoint and parked-input behavior through the shipped composition. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md new file mode 100644 index 0000000000..455a511778 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 用户控制按目标子代理寻址 + +Status: implemented + +[English](2026-10-22-subagent-target-user-controls.md) | 中文 + +## 问题 + +浏览器知道当前展示的子代理,但要求提供直接创建者和 continuable 标记,使每个控制调用方都要重复历史元数据。调用方提供的父级 id 只能检查地址一致性,不能确定调用者身份或权限。仅解析出 global 访问资源的调用也无法表达对单个目标 Session 的授权。 + +## 决策 + +浏览器提示词和 `interruptByUser` 仅按目标子 Session 寻址。每个 Remote 方法显式声明该 Session 为访问资源;Gateway 在附件接纳、恢复或取消之前检查调用的 principal。global 或父 Session 授权不意味着子 Session 授权。策略决定授予两个操作、其中一个操作,或继承访问权。同进程用户中断信任其调用方,不接受从线上传入的 principal。 + +提示词投递从驻留 Activation 或经过验证的持久化创建关系中解析在线根。仅恢复冷目标,创建者可以保持离线。共享投递保留精确在线根检查、关闭准入、取消、容量和最终 inbox 接纳。根 Session 不是浏览器子代理提示词端点的目标。用户中断直接寻址驻留 Activation,不读取持久化数据,也不恢复目标;目标缺席仍为无操作,中断保留停放的输入。 + +模型消息保留[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)中的精确在线发送者和同树规则。模型中断保留更严格的祖先规则。创建父级仍决定目录关系、历史地址一致性、祖先关系和完成通知接收者;它们不是浏览器控制凭证。 + +本决策取代[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.zh.md)对人工继续执行要求直接父级地址的部分。旧记录保留 inbox 和持久化继续执行的理由;整树通信决策拥有其中已取代的模型相邻关系和运行时所有权限制。Session 格式不变。 + +## 考虑过的替代方案 + +**保留父级检查作为授权。** 客户端提供父级 id,也能从历史中取得它。匹配该 id 不能授权 principal,还使控制依赖于发现元数据。 + +**要求浏览器提供根 id。** 服务端已经能从目标解析运行时所有者。再提供一个 id 只会增加一致性检查,不会授予权限;Agent 发送者仍需要独立解析的根来约束同树消息。 + +**统一用户和 Agent 的中断权限。** 用户获得目标授权后可以停止该目标,而 Agent 只能停止自己的后代。同树协作不赋予成员控制彼此执行的权限。 + +## 影响 + +Remote 调用方需要更新提示词字段、中断端点,以及非 owner 策略的操作和资源;迁移步骤由[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md)拥有。历史 URL 保留父级 id 用于一致性检查。冷目标的宿主提示词可能读取两次创建祖先:先查找在线根,再在恢复期间验证;不跨异步操作保留已验证祖先缓存。 + +Gateway 测试覆盖目标授权、仅 global 和仅父级授权的拒绝、独立的发送和中断操作、owner 访问,以及副作用前拒绝。继续执行测试覆盖创建者在线或离线时的嵌套和冷目标、根不可用,以及拒绝把根当作子代理目标。Web 中断场景通过交付组合验证重命名后的端点和输入停放行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml new file mode 100644 index 0000000000..81682a6981 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-22-subagent-tool-owned-communication.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md +/agent-note-subagent-tools-own-communication-guidance: + en: 0bd031aa81fbc18d + zh: c530e980826698ed +/agent-note-subagent-tools-own-communication-guidance/problem: + en: ac391b569d2bc7e9 + zh: a95f613aa2b330b8 +/agent-note-subagent-tools-own-communication-guidance/decision: + en: 52fb33963bedfe07 + zh: 75fe66d80f47323f +/agent-note-subagent-tools-own-communication-guidance/alternatives-considered: + en: c55d44c6414e0c4c + zh: 9fa2ab31843e259b +/agent-note-subagent-tools-own-communication-guidance/consequences: + en: 52f7234664c515ec + zh: 55ec7b54c044e9af diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md new file mode 100644 index 0000000000..2222cd5dd7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md @@ -0,0 +1,27 @@ +# Agent Note: Subagent tools own communication guidance + +Status: implemented + +English | [中文](2026-10-22-subagent-tool-owned-communication.zh.md) + +## Problem + +The continuation manager inspected a Symbol on the standard messaging tool before adding tool instructions and parent addressing to the initial task. This coupled orchestration to one tool implementation and made result-delivery guidance depend on tool installation. A published internal entry point carried both that marker and a host prompt adapter used only by tests. + +## Decision + +`tool-subagent-control` owns messaging instructions and parent addressing. Its logged runtime context gives delegated children their direct creator’s id from the Session header, explains when to send findings, and states that sending does not finish a turn. The context is emitted only for delegated children when the plugin’s own tool definition is admitted in the current scope and step; roots and ordinary Session forks receive none. Communication guidance follows inherited history without changing the shared system prompt or tool schema. + +The manager owns result delivery. Local activations with `delivery: 'parent'` receive automatic-result guidance even without messaging tools; caller-owned activations receive no parent-delivery promise. Settlement timing, recipients, output, and suppression of closing text after a final message to the parent remain unchanged. Tool visibility grants no messaging authority. + +This supersedes the Symbol identity and initial-task communication-guidance choices in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md). Its message attribution, fixed Steer delivery, and rejection of a separate report operation remain active. The internal entry point and prompt adapter are removed; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md) owns caller migration. + +## Alternatives considered + +**Keep a cross-package marker or add a service hook.** Tool registration already gives its owner the exact definition, and prompt assembly already supplies the current Agent. Another protocol or stored identity has no independent consumer. + +**Move all return guidance into the tool.** Automatic parent notification depends on activation delivery, not on messaging capability. The tool must not promise it to caller-owned activations or withhold it from children without tools. + +## Consequences + +Tests cover visible, hidden and replaced definitions, logged identity, plugin disposal, fork prefix reuse, and parent versus caller result delivery. Recorded-session snapshots cover the assembled prompt and durable runtime context. Tests needing host input use the existing private manager test helper; no production adapter exists solely to expose that helper. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md new file mode 100644 index 0000000000..21242507b7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Subagent 工具拥有通信指导 + +Status: implemented + +[English](2026-10-22-subagent-tool-owned-communication.md) | 中文 + +## 问题 + +继续执行管理器检查标准通信工具上的 Symbol,随后在初始任务中加入工具说明和父级地址。这让编排依赖一个工具实现,也让结果交付指导取决于是否安装工具。已发布的内部入口同时承载该标记和仅供测试使用的宿主 prompt 适配器。 + +## 决策 + +`tool-subagent-control` 拥有通信说明和父级寻址。持久记录的运行时上下文从 Session header 读取委派子级的直接创建者 id,说明何时发送发现,并指出发送不会结束轮次。仅当本插件的工具定义在当前作用域和 step 中可用时,才向委派子级提供上下文;根代理和普通 Session fork 不接收。通信指导位于继承历史之后,不改变共享系统提示词或工具 schema。 + +管理器拥有结果交付。采用 `delivery: 'parent'` 的本地 activation 即使没有通信工具,也会收到自动结果交付指导;调用方拥有的 activation 不会收到父级交付承诺。结算时序、接收方、输出,以及最后向父级发送消息后省略结束文本的规则保持不变。工具可见性不授予通信权限。 + +本决策取代[相邻代理通信](2026-08-27-adjacent-agent-steer-messaging.zh.md)中的 Symbol 身份和初始任务通信指导选择。该记录的消息来源归属、固定 Steer 投递,以及不另设 report 操作的决策继续有效。内部入口和 prompt 适配器已删除;调用方迁移由[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md)说明。 + +## 考虑过的替代方案 + +**保留跨包标记或增加 service hook。** 工具注册已经向所有者提供确切定义,提示词组装也已提供当前 Agent。另一套协议或身份存储没有独立消费者。 + +**把全部返回指导移入工具。** 自动父级通知取决于 activation 的交付方式,而非通信能力。工具不能向调用方拥有的 activation 承诺自动通知,也不能让没有工具的子级缺少交付说明。 + +## 后果 + +测试覆盖可见、隐藏及替换的定义、持久记录的身份、插件释放、fork 前缀复用,以及父级与调用方结果交付。录制会话快照覆盖组装后的提示词和持久运行时上下文。需要宿主输入的测试使用现有的私有 manager 测试帮助函数;生产代码不再仅为暴露该帮助函数而保留适配器。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml new file mode 100644 index 0000000000..de5a17b10d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-cua-dev-signing-identity.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md +/agent-note-development-signing-identity-for-source-built-cua-helpers: + en: 472c4efd47184a32 + zh: 49f64628531970e9 +/agent-note-development-signing-identity-for-source-built-cua-helpers/problem: + en: a24857a312482724 + zh: dbac75d363670e86 +/agent-note-development-signing-identity-for-source-built-cua-helpers/decision: + en: d2265f9ec5fb7a79 + zh: 05da2479b024a4bb +/agent-note-development-signing-identity-for-source-built-cua-helpers/alternatives-considered: + en: e30db5bc9c7aab43 + zh: fa84883b5527334e +/agent-note-development-signing-identity-for-source-built-cua-helpers/consequences: + en: fb0e8fe44890bdf9 + zh: d9e9ace1a4c10060 diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md new file mode 100644 index 0000000000..5f721474fc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md @@ -0,0 +1,35 @@ +# Agent Note: Development signing identity for source-built CUA helpers + +Status: implemented + +English | [中文](2026-10-29-cua-dev-signing-identity.zh.md) + +## Problem + +Source checkouts compile `DSH Computer Use.app` with an ad-hoc signature. macOS stores Accessibility and Screen Recording grants for a bundle identifier with the designated requirement of the granted code, and an ad-hoc requirement is the code hash. Every changed build and every other worktree therefore needs new grants, and these builds shared the release helper's bundle identifier and replaced its grant entry. + +## Decision + +The Host builds source checkouts as a development variant with bundle identifier `com.deepseek.harness.computer-use.dev` and name `DSH Computer Use Dev`, in `DSH Computer Use Dev.app`. The macOS privacy list and the helper’s reported launch application use the bundle filename, so that filename also includes Dev. The release variant and its packaging are unchanged. Grants stay with the helper application launched through LaunchServices, as the [shared helper decision](2026-10-09-cua-shared-native-helper.md) requires. + +`sourceSigning` belongs to the macOS provider and accepts only default `ad-hoc` or `{ identity, keychain }` selecting an existing certificate and absolute keychain path. Builds do not create, unlock or remove identities. Developer credentials stay separate from the user-facing DSH home, which stores only the certificate selection; the shared `cua` package owns no macOS signing configuration. + +The [developer signing skill](../../../skills/dsh-cua-macos-signing/SKILL.md) guides Agents through creating, reusing and unlocking an isolated identity under `~/.cua-macos-keys/`, then selecting its fingerprint and keychain path in user configuration. The tool stores a random password in a `0600` file, permits codesign access, and adds the dedicated keychain to the user search list. A file lock serializes setup and removal for that directory; a missing keychain with an existing record is preserved and rejected. A directory marker limits identity management to explicitly claimed, initially empty directories. Unexpected files or symlinked identity entries stop setup and removal before changes, so a developer-selected path cannot erase unrelated contents. + +Certificate signatures embed a signing time even without a timestamp server. Development publication records which generation each unsigned bundle and identity produced, then selects that generation again instead of re-signing, so identical sources keep one executable digest and one shared service. The cache uses certificate fingerprints; explicit certificate names are not reused because renewal can change the certificate behind a name. Signing failures stop publication. Developer setup diagnostics omit passwords and complete command arguments. The build CLI defaults to `--identity -` and accepts an existing certificate with `--identity` and optional `--keychain` for release packaging. + +## Alternatives considered + +**Grant the launching terminal.** Launching the service directly makes macOS attribute permissions to the terminal or editor that started DSH. Every program that application runs, including commands run by the model, then has the permissions. + +**Automatically create a fixed identity during source builds.** This makes ordinary startup create developer private keys and ties their lifetime to user data. An explicit developer workflow preserves the option to reduce repeated grants while keeping identity creation, removal and selection under developer control. + +**Self-signed identity in the login keychain.** The login keychain is already unlocked and on the search list, but allowing `codesign` to use a new key there without a dialog requires the user's login password. + +**Add the keychain to the search list only while signing.** The release packaging script does this for one invocation. Concurrent builds in several worktrees would read and restore the user list over each other, and a stopped build would leave the entry behind anyway. + +**A stable launcher that spawns each worktree's helper.** Grants would follow the launcher, but any local process could ask it to run an arbitrary executable with those grants, which is the same exposure as the stored key without a signature check. + +## Consequences + +Development builds signed by the same certificate share a signing requirement; actual permission retention after grants, rebuilds and worktree changes still requires verification. Other processes of the same user can read the stored password and sign with that identity; the release helper has a different bundle identifier. Deleting DSH home removes its selection, not the developer identity; deleting the identity requires clearing every configuration that references it. The real-signing e2e checks reuse, unlocking, requirements across output directories and search-list restoration after removal. diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md new file mode 100644 index 0000000000..9491738cad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 源码构建的 CUA helper 使用开发签名身份 + +Status: implemented + +[English](2026-10-29-cua-dev-signing-identity.md) | 中文 + +## 问题 + +源码检出用临时签名编译 `DSH Computer Use.app`。macOS 按 Bundle ID 和被授权代码的指定要求保存辅助功能与屏幕录制授权,临时签名的指定要求就是代码哈希。因此每次构建内容变化、每换一个 worktree,都需要重新授权;这些构建还与正式 helper 共用 Bundle ID,会替换正式版的授权条目。 + +## 决策 + +Host 以开发版构建源码检出,Bundle ID 为 `com.deepseek.harness.computer-use.dev`,名称为 `DSH Computer Use Dev`,应用包文件名为 `DSH Computer Use Dev.app`。macOS 隐私列表和 helper 返回的启动应用名称使用应用包文件名,因此文件名也需包含 Dev。正式版及其打包流程不变。按[共享 helper 决策](2026-10-09-cua-shared-native-helper.zh.md)的要求,授权仍归经 LaunchServices 启动的 helper 应用。 + +`sourceSigning` 由 macOS provider 定义,只接受默认的 `ad-hoc` 和指定已有证书及钥匙串绝对路径的 `{ identity, keychain }`。构建不创建、解锁或移除身份。开发者凭据与面向用户的 DSH home 分开,后者只保存选择证书的配置;共享 `cua` 包不持有 macOS 签名配置。 + +[开发者签名 skill](../../../skills/dsh-cua-macos-signing/SKILL.md)指导 Agent 在 `~/.cua-macos-keys/` 创建、复用和解锁独立身份,将证书指纹和钥匙串路径写入用户配置。工具把随机密码存入 `0600` 文件,允许 codesign 访问私钥,并把专用钥匙串加入用户搜索列表。文件锁让同一目录的初始化和移除串行执行;已有记录但钥匙串缺失时保留记录并报错。目录标记将身份管理限制在明确占用的初始空目录中。发现无关文件或身份文件软链接时,初始化和移除都会在修改前停止,避免开发者指定的路径导致无关内容被删除。 + +即使不使用时间戳服务器,证书签名也包含签名时间。开发构建记录每份未签名内容与身份对应的已签名目录,再次构建相同内容时复用该目录,保持可执行文件摘要和共享服务一致。缓存使用证书指纹;按证书名称选择时每次签名,避免续期后复用旧证书。签名失败会中止发布。开发者工具的诊断不包含密码或完整命令参数;构建 CLI 默认 `--identity -`,支持指定证书,正式打包也可省略 `--keychain`。 + +## 考虑过的替代方案 + +**把权限交给启动终端。** 直接启动服务时,macOS 把权限归属到启动 DSH 的终端或编辑器。该应用运行的所有程序,包括模型执行的命令,都会带上这些权限。 + +**源码构建自动创建固定身份。** 这会让普通启动生成开发者私钥,并把身份生命周期绑定到用户数据目录。显式开发者操作保留了减少重新授权的选择,也让证书的创建、移除和使用范围由开发者决定。 + +**把自签名身份放进登录钥匙串。** 登录钥匙串已解锁且在搜索列表中,但要让 `codesign` 不弹窗地使用其中的新私钥,需要用户的登录密码。 + +**只在签名期间把钥匙串加入搜索列表。** 正式打包脚本对单次调用这样处理。多个 worktree 并发构建时会互相覆盖读取和恢复的列表,构建被中断时条目也会残留。 + +**用固定的启动器运行各 worktree 的 helper。** 授权会跟随启动器,但任何本机进程都能让它带着这些授权运行任意可执行文件,暴露面与保存的私钥相同,还少了签名检查。 + +## 后果 + +同一证书签出的开发构建共用签名要求;授权后重建和跨 worktree 的实际权限保持仍需验证。同一用户的进程可读取保存的密码并使用该身份签名,正式 helper 使用不同的 Bundle ID。删除 DSH home 只移除配置,不删除开发者身份;删除该身份后应清理所有引用它的配置。真实签名 e2e 验证复用、解锁、不同输出目录的签名要求和移除后的搜索列表恢复。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.i18n.yaml new file mode 100644 index 0000000000..8333c9eff2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-retained-ptc-interruption.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md +/agent-note-retained-ptc-interruption: + en: 482adc6f938bc494 + zh: 2bcc4856df4fb39e +/agent-note-retained-ptc-interruption/problem: + en: 1d2ac1280f14aa80 + zh: c5875c914cf23d73 +/agent-note-retained-ptc-interruption/decision: + en: 77f44a7ce832934c + zh: 4744934c60ca6f3d +/agent-note-retained-ptc-interruption/alternatives-considered: + en: 8c57872ffaf660b2 + zh: 4765cbf561ea5dc9 +/agent-note-retained-ptc-interruption/consequences: + en: f679322d48a740e9 + zh: 4a255df6e019a32c diff --git a/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md new file mode 100644 index 0000000000..a1376e985e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.md @@ -0,0 +1,23 @@ +# Agent Note: Retained PTC interruption + +Status: implemented + +English | [中文](2026-10-29-retained-ptc-interruption.zh.md) + +## Problem + +Terminating a retained Node process on every timeout or cancellation loses unrelated application objects and imported modules. A control handler on the evaluation thread cannot stop a synchronous loop. The [in-memory execution decision](2026-09-23-best-effort-persistent-ptc.md) still owns disposable state and Session behavior; this decision replaces its timeout and cancellation cleanup policy. + +## Decision + +The host revokes execution authority immediately and requests cooperative stop over the existing control channel. The runtime rejects pending bindings; the original `run()` response confirms that evaluation has settled. Revocation is a one-way notification. The ordered pipe and single active evaluation determine notification ownership; RPC requests associate replies with callers. Acknowledgment permits reuse of the VM and retained objects; earlier mutations remain. Callers await acknowledgment or fallback process cleanup before starting another evaluation. + +The host bounds acknowledgment with `stopGraceMs`. Missing or failed acknowledgment terminates the managed process range and awaits cleanup. Results retain the original timeout or cancellation kind and report whether variables survived. Confirmed termination allows a fresh environment on the next call; failed cleanup blocks reuse until host release succeeds. Explicit reset, disposal and policy invalidation terminate immediately. Reset uses the existing release path and exposes no additional model tool. + +## Alternatives considered + +Unconditional termination unnecessarily discards state during ordinary asynchronous waits. Cooperative stop alone cannot bound synchronous loops. Inspector termination is unsuitable as a state-preserving default: local probes on Node 24.9 and 26.6 crashed native async-context handling when terminating an awaited VM loop with an enabled async hook; Node 24 REPL also crashed without an explicit user hook. A successful Inspector command does not establish that the runtime remains reusable. Any future forced in-process interruption must demonstrate safe recovery with the supported Node versions and async hooks. + +## Consequences + +Canceled tools throw normally, allowing user `catch` and `finally` handlers to run. A blocked handler or unresolved user Promise prevents acknowledgment and triggers fallback termination. Timers, arbitrary user Promises, I/O callbacks and user-created Workers or subprocesses have no per-evaluation cancellation guarantee and can still mutate retained state after acknowledgment. Acknowledgment confirms revocation of managed execution, not cancellation of all Node activity. Fallback termination loses all state in that process; neither path rolls back external effects. Host reset remains available when retained resources cannot be trusted. diff --git a/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.zh.md b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.zh.md new file mode 100644 index 0000000000..257752abf5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-retained-ptc-interruption.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 保留状态的 PTC 中断 + +Status: implemented + +[English](2026-10-29-retained-ptc-interruption.md) | 中文 + +## 问题 + +每次超时或取消都终止保留的 Node 进程,会丢失无关的应用对象与导入模块。执行线程上的控制处理器无法停止同步死循环。[内存执行决策](2026-09-23-best-effort-persistent-ptc.zh.md)仍负责可丢弃状态与 Session 行为;本决策替代其中的超时与取消清理策略。 + +## 决策 + +宿主立即撤销本次执行的权限,并通过现有控制通道请求协作式停止。运行时拒绝未完成的绑定,由原 `run()` 响应确认该次执行已结束。撤销是单向通知。有序管道和唯一的活动执行确定通知归属;RPC 请求将回复关联到调用方。收到确认后可复用 VM 和保留对象;此前发生的修改继续存在。调用方等待确认或兜底进程清理完成后,才能开始下一次执行。 + +宿主通过 `stopGraceMs` 限制确认等待时间。未收到确认或确认失败时,终止受管进程范围并等待清理。结果保留原来的超时或取消类型,并报告变量是否保留。确认终止后,下一次调用可以创建新环境;清理失败则阻止复用,直到宿主释放成功。显式重置、销毁和策略失效立即终止进程。重置复用现有释放路径,不向模型增加工具。 + +## 考虑过的替代方案 + +无条件终止会在普通异步等待期间丢弃本可保留的状态。仅靠协作式停止无法限制同步死循环。Inspector 终止不适合作为保留状态的默认机制:本地探针在 Node 24.9 和 26.6 启用 async hook 后,终止 await 后的 VM 死循环会导致原生异步上下文处理崩溃;Node 24 REPL 在未显式启用用户 hook 时也会崩溃。Inspector 命令成功不能说明运行时仍可复用。未来若引入进程内强制中断,必须验证支持的 Node 版本及 async hooks 下能安全恢复。 + +## 影响 + +取消的工具正常抛错,允许用户的 `catch` 和 `finally` 运行。处理器阻塞或用户 Promise 无法完成时,不能确认停止,将触发兜底终止。定时器、任意用户 Promise、I/O 回调及用户创建的 Worker 或子进程不承诺按执行取消,收到确认后仍可能修改保留状态。确认表示受管执行已撤销,不表示所有 Node 活动均已取消。兜底终止会丢失该进程中的全部状态;两条路径均不回滚外部效果。保留资源不可信时,仍可使用宿主重置。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml new file mode 100644 index 0000000000..f54423c675 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-29-web-provider-selection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md +/agent-note-web-provider-selection-independent-menus-over-registered-providers: + en: 2a2fa1def8a81625 + zh: 317d8b115f34e677 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/problem: + en: 3f5dacf86015cebb + zh: cf0d5bceb9941554 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision: + en: 0682ebef17f6a5e7 + zh: 6ea399459262eb0c +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/registered-providers-are-observable-availability-is-not: + en: 38ce796dea91fa35 + zh: d312a9cd29fd5cc9 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/independent-selection: + en: 9d68ffca5c8be681 + zh: 36bb661f98de0ebb +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution: + en: 32f59c35d71a4832 + zh: e9288313bb18b78d +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections: + en: 5fcd3319fbceed1d + zh: 20771646b9c36fa6 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/legacy-messages-search: + en: 2c7c84f8fa1e8745 + zh: 5d0244e4f17ba12a +/agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered: + en: 0c227e270894cd71 + zh: 114484a3e69f5c0a +/agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences: + en: 50f046bf81a7783d + zh: 5c0e27bdcaf9f12e +/agent-note-web-provider-selection-independent-menus-over-registered-providers/verification: + en: 26ade0695094e9bc + zh: 7b265c43b1886da5 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md new file mode 100644 index 0000000000..1d37c118ec --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md @@ -0,0 +1,85 @@ +# Agent Note: Web provider selection - independent menus over registered providers + +Status: implemented + +English | [中文](2026-10-29-web-provider-selection.zh.md) + +## Problem + +The web service selects search and fetch providers through two independent fields, `web.searchProvider` and `web.fetchProvider`. A settings control that writes both at once ties fetch billing and target access policy to the search choice, and a control that offers only the DeepSeek choices it knows cannot select a provider that another plugin registers. A picker over registered providers needs the Host to report them, and the [web capability seam](2026-06-24-web-capability-seam.md) has no availability observation for it to reuse. Messages search starts an auxiliary model request for each query, whereas the [Search API](../feature/2026-10-15-deepseek-search-fetch-api.md) returns structured sources directly. The Search and Fetch APIs also need a DeepSeek credential, and a credential setting of their own makes every user reconcile it with the model provider they chat with. + +## Decision + +Users choose search and fetch providers independently from the Host's registered provider lists. The shipped composition selects the Search API for search and local HTTP fetch for fetch, and the DeepSeek Search and Fetch APIs follow the conversation's DeepSeek credential. + +### Registered providers are observable; availability is not + +`WebSearchProvider` and `WebFetchProvider` carry an optional `title`, the provider's display name, and an optional localized `description` of what the provider suits and what it costs or cannot do. `ctx.web.listProviders()` returns `{ search, fetch }`, each a registration-ordered list of `{ id, title?, description? }` entries, and the `web` Remote namespace exposes it as `web/listProviders`. Every registration and disposal emits the payload-free `web/providers-changed` event after the registry changes; the web service contains listener failures, so an observer cannot veto a registry change. [dsh-api-remotes](../../../../packages/api/remotes/README.md) mounts the namespace and forwards the event, and Clients re-read the list after each notification and reconnect. + +The lists include providers whose `available()` answers false. A picker shows what can be pinned, including a provider whose credential the user has not configured yet, whereas availability depends on per-request facts such as the Session's model route and credential state. Availability therefore stays an execution result: `search()` and `fetch()` resolve the configured provider at call time and throw the structured `WebError`, as the seam note records. The tool layer still never enumerates providers. + +### Independent selection + +The Web search and fetch page writes `web.searchProvider` from its Search menu and `web.fetchProvider` from its Fetch menu. Each menu lists every registered provider for its capability by `title` (or `id` when `title` is absent), so plugin providers appear without page changes, and a selected id that the Host does not register stays visible as such. Official providers register no `title`; the page names them from its own locale dictionary and lists them first in a fixed order: the Search API (`deepseek-search-api`) followed by Messages search (`deepseek-official`) for search, and local HTTP fetch (`http`) followed by the Fetch API (`deepseek-fetch-api`) for fetch. Other providers follow in Host registration order. Selecting the deployment's composed provider clears the stored override instead of storing a duplicate value. The [settings page README](../../../../packages/client/ui-settings-web-search/README.md) owns the page's controls. + +Every registered provider is listed; a provider has no flag that keeps it out of the menus. This note supersedes the [Search and Fetch API decision](../feature/2026-10-15-deepseek-search-fetch-api.md)'s rejection of listing every registered provider in a settings dropdown. Independent menus must offer providers that other plugins register, and the display metadata that listing needs is only the optional `title` and `description`. The description lives on the registration rather than in the page's dictionaries so that official and plugin providers explain themselves through one field, and a new provider needs no page change to describe its trade-offs. The rejected generic editor still stands: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. + +The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolves its own credential and endpoint, as the next section records. Messages search reads `web-search-deepseek` for its independent endpoint and key reference. + +### Credential resolution + +Search and Fetch follow the DeepSeek credential of the conversation, and the settings page shows no credential control for them. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan. + +On the `deepseek-account` route, with an active, valid `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, the operation sends only the account token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin with `/v1/tools`; an HTTP 401 invalidates that token. Every other operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing key makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance naming the Models page, the reference, and the account sign-in. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` without reading a credential or sending the request, so a broken gateway setting never redirects requests to the official endpoint. + +A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect. + +The [credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) of `@deepseek-ai/dsh-llm-deepseek` read and validate API keys, check key presence, and release account tokens for the DeepSeek model routes, Messages search, and the Search and Fetch APIs, so every DeepSeek request reads the credential store the same way; the account model route, Messages search, and the Search and Fetch APIs also invalidate the token that an HTTP 401 rejects. Shipped profiles need no per-profile credential override, because the API-key path works wherever `DEEPSEEK_API_KEY` or the DeepSeek API-key route has a key. + +### Defaults and stored selections + +The [base composition](../../../../packages/bundle/base/cordis.patch.yml) sets `searchProvider: deepseek-search-api` and `fetchProvider: http`. Search defaults to the Search API because it returns sources without Harness starting an auxiliary model request. Fetch defaults to local HTTP fetch because it has no per-request API charge, applies Harness's own URL validation and resource limits, and reaches intranet and local addresses. The Fetch API remains one menu choice away. + +A stored selection, whether in the settings document or in a deployment's own composition, wins over the shipped default, and an upgrade does not rewrite it. A selection is never replaced at execution: when the selected provider is missing, unavailable, or fails, the operation fails with its structured error and does not fall back to another search or fetch provider. A fallback would send the query and credential to an endpoint the user did not choose and change billing without notice. + +### Legacy Messages search + +The DeepSeek Messages search provider remains available as **DeepSeek (legacy)**. A stored `web.searchProvider: deepseek-official` (or `DSH_WEB_SEARCH_PROVIDER=deepseek-official`) selects it, so users whose gateways serve Messages search but not `/v1/tools` can retain their working provider. + +The Messages provider owns and writes `web/deepseek-search-llm-request`. The declaration and payload retain compatibility with released Sessions; the Search and Fetch API package neither declares nor writes this auxiliary event. + +## Alternatives considered + +**Keep one switch for both APIs.** It lets Fetch API adoption need no configuration edit, but it ties fetch billing and target access policy to the search choice, and it cannot express a provider that another plugin registers. + +**Filter the lists by availability, or add availability to each entry.** A filtered list hides a provider while its credential is missing, right when the user needs to choose and configure it. Availability also depends on the request's model route and credential state, so a listed boolean could disagree with execution. The archived [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) allows only the smallest signal a consumer reads; the menus read registration only. + +**Keep a fixed provider catalog in the Client.** The Client would show ids the Host does not register as normal choices and would never show a provider that a plugin registers at runtime. + +**Carry the provider list in the change event.** A payload-free event leaves one read path for initial load, notifications, and reconnects, so the Client never has to order an event payload against a concurrent read. + +**Keep display names in the Client or in a separate Host registry.** The registering plugin knows its provider's name; a separate map would let a provider and its label register or dispose independently. + +**Fall back to another provider when the selected one fails.** A fallback hides gateway and credential misconfiguration, sends the query to a second service, and changes what the request costs without the user choosing it. + +**Remove Messages search and let a stored `deepseek-official` select the Search API.** Gateways can support Messages search without Search API routes. Retaining the legacy provider preserves that choice instead of changing its protocol and billing. + +**Default fetch to the Fetch API as well.** It adds a per-request charge to every fetch and delegates page retrieval and target access policy to the service, which fetches from its own side rather than from the user's machine. + +**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. Following the conversation's credential needs no setting, and the YAML-only own fields cover endpoints that differ from the model provider. + +**Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways. + +**Fail an account conversation without a sign-in instead of sending the API key.** It would keep the account route from ever billing the API key, but signed-out account conversations would lose web tools even when a DeepSeek API key is configured. + +**Send the account token whenever the user is signed in, whatever the conversation's route.** It would bill web access to the account during conversations on the API-key route or on another provider, which the user did not choose for them. + +## Consequences + +Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search only in conversations on the account route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup. + +A plugin provider, including one written in creator mode, appears in the matching menu as soon as it registers, under its `title`. The menus can offer a provider that cannot run for the current request; the failure appears when a search or fetch runs. Each registry change costs one forwarded event and one list read in each Client that observes it. + +## Verification + +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the absence of credential controls for the DeepSeek APIs, and the settings each provider keeps. diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md new file mode 100644 index 0000000000..7b9b5a75da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md @@ -0,0 +1,85 @@ +# Agent Note: Web 提供方选择——基于已注册提供方的独立下拉框 + +Status: implemented + +[English](2026-10-29-web-provider-selection.md) | 中文 + +## 问题 + +Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 两个独立字段分别选择搜索与抓取提供方。用一个设置控件同时写入两者,会把抓取的计费和目标访问策略绑在搜索选择上;只提供已知 DeepSeek 选项的控件,也无法选用其他插件注册的提供方。基于已注册提供方的选择器需要 Host 报告这些提供方,而 [Web 能力 seam](2026-06-24-web-capability-seam.zh.md) 没有可供复用的可用性观察接口。Messages 搜索每次查询都会发起一次辅助模型请求,而 [Search API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md) 能直接返回结构化来源。Search 与 Fetch API 还需要 DeepSeek 凭据;为它们单独设置凭据,会让每个用户都要把它与自己对话所用的模型提供方对齐。 + +## 决策 + +用户从 Host 已注册的提供方列表中分别选择搜索和抓取提供方。内置组合将搜索设为 Search API,将抓取设为本地 HTTP 抓取;DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据。 + +### 已注册的提供方可观察,可用性不可观察 + +`WebSearchProvider` 和 `WebFetchProvider` 带有可选的 `title`(提供方的显示名称)和可选的多语言 `description`(说明该提供方适合什么、有什么代价或做不到什么)。`ctx.web.listProviders()` 返回 `{ search, fetch }`,二者均为按注册顺序排列的 `{ id, title?, description? }` 列表,`web` Remote namespace 将其暴露为 `web/listProviders`。每次注册和注销都会在注册表变更后触发无载荷的 `web/providers-changed` 事件;Web 服务会隔离监听器异常,因此观察者无法阻断注册表变更。[dsh-api-remotes](../../../../packages/api/remotes/README.zh.md) 挂载该 namespace 并转发该事件,Client 在收到通知和重连后重新读取列表。 + +列表包含 `available()` 返回 false 的提供方。选择器展示的是可固定选用的项,包括用户尚未配置凭据的提供方;而可用性取决于会话的模型路由、凭据状态等单次请求条件。因此可用性仍是执行期结果:`search()` 和 `fetch()` 在调用时解析所配置的提供方并抛出结构化 `WebError`,与 seam 记录一致。工具层仍从不枚举提供方。 + +### 分别选择 + +「网页搜索与获取」页由 Search 下拉框写入 `web.searchProvider`,由 Fetch 下拉框写入 `web.fetchProvider`。每个下拉框以 `title`(缺省时用 `id`)列出该能力的所有已注册提供方,因此插件提供方无需修改页面即可出现,且 Host 未注册的已选 id 仍会照常显示。官方提供方不注册 `title`,由页面用自己的语言字典命名,并按固定顺序排在最前:搜索依次为 Search API(`deepseek-search-api`)与 Messages 搜索(`deepseek-official`),抓取依次为本地 HTTP 抓取(`http`)和 Fetch API(`deepseek-fetch-api`)。其他提供方按 Host 注册顺序排列。选择部署组合的默认提供方会清除已保存的覆盖值,而非冗余保存一份副本。[设置页 README](../../../../packages/client/ui-settings-web-search/README.zh.md) 负责页面控件说明。 + +所有已注册的提供方都会列出;提供方没有可以把自己排除在下拉框之外的标志。本 Note 取代了 [Search 与 Fetch API 决策](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)对「在设置页用下拉框列出所有已注册的提供方」的否决。独立的下拉框必须提供其他插件注册的提供方,而列出提供方所需的展示元数据只是可选的 `title` 和 `description`。说明放在注册信息里而不是页面词典里,这样官方与插件提供方都通过同一个字段说明自己,新提供方无需修改页面即可说明其取舍。被否决的通用编辑表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。 + +Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解析自己的凭据和端点,见下一节。Messages 搜索从 `web-search-deepseek` 读取独立的端点和密钥引用。 + +### 凭据解析 + +Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。 + +在 `deepseek-account` 路由上,若模型提供方目录列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 加 `/v1/tools` 下的请求端点给出的账号令牌;HTTP 401 会作废该令牌。其他所有操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少密钥时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息点名模型页面、该引用和账号登录。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并以 `WEB_PROVIDER_MISCONFIGURED` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。 + +在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。 + +`@deepseek-ai/dsh-llm-deepseek` 的[凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)为 DeepSeek 模型路由、Messages 搜索以及 Search 与 Fetch API 读取并校验 API 密钥、检查密钥是否存在、取得账号令牌,因此每个 DeepSeek 请求都以同一方式读取凭据库;账号模型路由、Messages 搜索以及 Search 与 Fetch API 还会作废被 HTTP 401 拒绝的令牌。内置配置不需要按 profile 覆盖凭据,因为只要 `DEEPSEEK_API_KEY` 或 DeepSeek API 密钥路由有密钥,API 密钥路径就能工作。 + +### 默认值与已保存的选择 + +[base 组合](../../../../packages/bundle/base/cordis.patch.yml)设置 `searchProvider: deepseek-search-api` 和 `fetchProvider: http`。搜索默认使用 Search API,因为它返回来源时无需 Harness 发起辅助模型请求。抓取默认使用本地 HTTP 抓取,因为它没有按次的 API 费用,执行 Harness 自身的 URL 校验和资源限制,并且可以访问内网和本地地址。Fetch API 仍可在下拉框中直接选择。 + +已保存的选择——无论位于设置文档还是部署自身的组合中——优先于内置默认值,升级不会改写它。执行时从不替换选择:所选提供方缺失、不可用或失败时,操作以结构化错误失败,不会回退到其他搜索或抓取提供方。回退会把查询和凭据发往用户未选择的端点,并在不提示的情况下改变计费。 + +### 旧版 Messages 搜索 + +DeepSeek Messages 搜索提供方仍以 **DeepSeek(旧版)**提供。已保存的 `web.searchProvider: deepseek-official`(或 `DSH_WEB_SEARCH_PROVIDER=deepseek-official`)会选中它,因此网关支持 Messages 搜索但不提供 `/v1/tools` 的用户可以保留可用的提供方。 + +Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声明和载荷保持与已发布会话兼容;Search 与 Fetch API 包既不声明也不写入这一辅助事件。 + +## 考虑过的替代方案 + +**保留一个同时切换两个 API 的开关。** 它让使用 Fetch API 无需修改配置,但把抓取的计费和目标访问策略绑在搜索选择上,也无法表达其他插件注册的提供方。 + +**按可用性过滤列表,或在每个条目中加入可用性。** 过滤后的列表恰好会在凭据缺失时隐藏提供方,而这正是用户需要选择并配置它的时候。可用性还取决于请求的模型路由和凭据状态,列表中的布尔值可能与实际执行结果不一致。已归档的[观察面决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)只允许引入消费方实际读取的最小信号;下拉框只读取注册情况。 + +**在 Client 中维护固定的提供方目录。** Client 会把 Host 未注册的 id 当作正常选项展示,且永远无法展示插件在运行时注册的提供方。 + +**在变更事件中携带提供方列表。** 无载荷事件让初次加载、变更通知和断线重连共用同一条读取路径,Client 无需为事件载荷与并发读取结果排序。 + +**把显示名称放在 Client 或单独的 Host 注册表中。** 注册插件最清楚自身提供方的名称;拆成独立映射会让提供方与其标签各自独立注册或注销。 + +**所选提供方失败时回退到其他提供方。** 回退会掩盖网关和凭据的配置错误,将查询发往第二个服务,并在未经用户选择的情况下改变请求的费用。 + +**移除 Messages 搜索,让已保存的 `deepseek-official` 选中 Search API。** 网关可能支持 Messages 搜索,却没有 Search API 路由。保留旧版提供方可以保留这一选择,而不改变其协议和计费。 + +**抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。 + +**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。 + +**Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。 + +**账号会话未登录时直接失败,而不是发送 API 密钥。** 这样账号路由永远不会消耗 API 密钥,但即使配置了 DeepSeek API 密钥,未登录的账号会话也会失去网页工具。 + +**只要用户已登录就发送账号令牌,不论会话的路由。** 这会让在 API 密钥路由或其他提供方上进行的会话把 Web 访问记到账号上,而用户并没有为这些会话这样选择。 + +## 影响 + +没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户只在账号路由上的会话中可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。 + +插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `title` 出现在对应的下拉框中。下拉框可能提供在当前请求中无法运行的提供方;失败会在执行搜索或抓取时出现。每次注册表变更会在每个观察它的 Client 中产生一次转发事件和一次列表读取。 + +## 验证 + +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 不显示凭据控件,以及各提供方保留的设置。 diff --git a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml new file mode 100644 index 0000000000..8826b372a2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-11-03-hoc-slot-kind.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers: + en: 8292b8f9884b8802 + zh: 3a4f34ca79313c9d +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/problem: + en: d16c3bfef49e16be + zh: 6714da1c9c984fe9 +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision: + en: 3a4380324cfc1679 + zh: 44db368e091cbd46 +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision/implementation: + en: 01189e457c91deeb + zh: 94f7e8865b332a07 +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/decision/first-consumer: + en: 1afdf4ceeef7a3b3 + zh: be01cf39850db0ca +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/alternatives-considered: + en: b4824619c502b422 + zh: 610364f2dc8cb2b7 +/agent-note-add-the-hoc-slot-kind-so-plugins-can-wrap-owner-content-in-layers/consequences: + en: 7542ee2bc2c70258 + zh: bc0dd1dc6e50de66 diff --git a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md new file mode 100644 index 0000000000..ccfb0d11f9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.md @@ -0,0 +1,72 @@ +# Agent Note: Add the `hoc` slot kind so plugins can wrap owner content in layers + +Status: implemented + +English | [中文](2026-11-03-hoc-slot-kind.zh.md) + +## Problem + +The home whale plays when blank space is clicked. Its canvas must paint below the content, and `.scrollBody` fills the whole body, so the canvas itself never receives clicks; only an ancestor of the content does. + +No existing slot kind lets a plugin become an ancestor of the content: + +| Kind | Behavior | Why it is not enough | +|---|---|---| +| `single` / `list` / `keyed` | Render the occupant at an owner-chosen position | The occupant is a sibling of the content | +| `chain` | The elected entry replaces the fallback; with `overlay` it renders beside the fallback | It does not wrap | +| Factory local slot | The caller replaces a local Component per occurrence | Only the caller chooses; plugins cannot stack | + +The previous approach had the Conversation classify blank clicks on its body and notify the background plugin through `onBlankClick(listener) => dispose`. The Conversation thereby maintained click classification and subscription forwarding that served only a decorative plugin. + +## Decision + +Add a fifth kind: `hoc`. + +| Item | Contract | +|---|---| +| Declaration | The parent entry's `children` lists `{ kind: 'hoc', scope }`; `SlotMap` declares `kind: 'hoc'` | +| Rendering | `renderSlot(key, owner, { children })`; `entryKey` / `only` / `fallback` / `hookContext` are unavailable; omitted `children` renders the layers around nothing | +| Registration | Same as `list`: required `id`, optional `order` and `priority`; the same `id` at the same `priority` throws, a different `priority` shadows | +| Nesting order | Ascending `order`, then `priority`, then registration order; lowest outermost | +| Layer props | Every layer receives the same owner props plus the framework-supplied `children` (the next inner layer; the innermost is the owner content); `children` is not an owner prop, `HocShare` types it, and owner props must not use that name | +| Layer obligation | Render `children` exactly once without creating a box of its own; do not change the props passed inward | +| No entry | `children` renders unchanged | +| Layer crash | Each layer has its own error boundary; a crashed layer renders its `children` and abdicates, so the next render omits it | +| Content error | An error thrown by the wrapped content passes every layer as its original error and is reported against no layer | +| Remounting | Adding or removing a layer remounts the content inside it; a `session` or `session-maybe` layer also remounts it when its Session binding changes | + +### Implementation + +| Package | Realization | +|---|---| +| `ui-slots` | `SlotKind` includes `'hoc'`; `HocRenderOpts`, `HocKeysOf`, `HocShare`; `RenderSlotFn` has a hoc-key overload taking optional `{ children }`; `KindOptions` has a hoc branch; `SlotCore.register` / `entriesOfSlot` key hoc cells by `id` | +| `ui-renderer` | `HocOutlet` calls `composeHocLayers` when the slot's ledger version or declaration changes (`useMemo` on the outlet instance) and nests the layers into one render function. Each layer is one Component that fixes its scope-specific Entry (`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`), entry, slot inject, and crash reporting at composition, and receives only `owner` and the next inner layer when rendering. With no layer the outlet returns the owner content directly. `HocContentBoundary` wraps each layer's inner content and rethrows its errors as `HocContentError`, which layer boundaries pass through and `HocOutletBoundary` unwraps to the original error. The Entry chain passes `children` to the registered Component as JSX children; non-hoc entries receive `undefined`. `SlotErrorBoundary` takes a `crashed` replacement node | +| `scripts/gen-client-catalog.ts` | Register options, a `children`-rendering example, and the `wraps-owner-content` replace risk for hoc slots | + +### First consumer + +| Location | Realization | +|---|---| +| `ui-conversation` | `conversation.hero.frame` (hoc, root) with owner props `{ hero: boolean }` wraps `.scrollBody` in every phase, so the composer does not remount when the home ends. On the home (`.root[data-phase='hero']`), the Conversation root, which paints the background, sets `isolation: isolate` as part of the frame contract, so layer decorations at negative `z-index` paint above that background and below all Conversation content. The Conversation performs no click classification for its layers | +| `ui-brand-official` | `PixelWhale` is a frame layer: a React `onClick` on a `display: contents` frame plays the whale for primary clicks on blank space, excluding portaled overlays (`currentTarget.contains(target)`), controls (a selector), and clicks that end a text selection inside the frame. While `hero` is true it paints a canvas at negative `z-index`. The frame atlas loads through the package asset route; the renderer returns `{ play, dispose }` | + +## Alternatives considered + +- `onBlankClick` subscription forwarding: the Conversation owns click classification that serves only a decorative plugin. +- Canvas below with `pointer-events: none` on `.scrollBody`: blank space cannot scroll when the home overflows. +- A canvas with no `z-index`: it needs no stacking context, but it paints over every unpositioned Conversation content outside the `z-index: 1` home composer stack, such as composer takeovers, and depends on that unrelated rule. +- A layer box that establishes the stacking context itself: the layer has to mirror the body's flex column layout. +- A separate `renderNestedSlot` or a JSX `` component: one more set of bindings; `hookContext` already shows that `renderSlot` can constrain its third argument per key. +- Precompiling every slot kind per ledger version: no measurable end-to-end time difference, see #7838. + +## Consequences + +- Adding or removing a layer remounts everything inside it, including the composer on the home. +- Every non-hoc registered Component receives `children: undefined`, which overrides any owner prop of that name. +- Each layer adds one Component and two error boundaries to the tree; each hoc slot with layers adds one outlet boundary. +- The outlet's `[data-slot]` element sits between the owner and its content, so owner selectors cannot assume the content is a direct child. +- React 18 recollects the component stack where an error is rethrown, so a content error reaching boundaries above a hoc slot with layers carries a component stack that starts at `HocOutletBoundary`. +- A layer that wraps its `children` in its own error boundary catches the internal `HocContentError` rather than the original content error. +- The frame contract holds only on the home: other phases leave the Conversation root out of the stacking order, so unportaled Conversation popovers still rise above AppFrame chrome that floats over the column, while on the home they stack only within the root. +- `HocOutlet` keeps its layer Components through `useMemo`, as Factory local Components keep theirs; if React ever discards memoized values, both need an explicit cache, because these Components are the remount boundary of the content inside them. +- [The slot system standard](2026-07-22-slot-type-chain-implementation.md) remains authoritative for the other kinds; this note adds the fifth. diff --git a/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md new file mode 100644 index 0000000000..f09a37fede --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-03-hoc-slot-kind.zh.md @@ -0,0 +1,72 @@ +# Agent Note: 新增 `hoc` 插槽类型,让插件层层包裹宿主内容 + +Status: implemented + +[English](2026-11-03-hoc-slot-kind.md) | 中文 + +## 问题 + +首页鲸鱼需要在空白处被点击时播放。canvas 必须画在内容下面,`.scrollBody` 铺满整个 body,所以 canvas 自己收不到点击;能收到点击的只有内容的祖先节点。 + +现有插槽类型都不能让插件成为内容的祖先: + +| 类型 | 行为 | 为什么不够 | +|---|---|---| +| `single` / `list` / `keyed` | 在宿主指定位置渲染 occupant | occupant 和内容是兄弟节点 | +| `chain` | 选中的 entry 替换 fallback;`overlay` 时与 fallback 并排 | 不包裹 | +| Factory 局部 slot | 调用方按次替换局部组件 | 只有调用方能选,插件无法叠加 | + +此前的做法是由 Conversation 在 body 上判定空白点击,再通过 `onBlankClick(listener) => dispose` 通知背景插件。这让 Conversation 维护一套只服务于装饰插件的点击分类和订阅转发。 + +## Decision + +新增第五种 kind:`hoc`。 + +| 项 | 约定 | +|---|---| +| 声明 | 父 entry 的 `children` 写 `{ kind: 'hoc', scope }`;`SlotMap` 中 `kind: 'hoc'` | +| 渲染 | `renderSlot(key, owner, { children })`;`entryKey` / `only` / `fallback` / `hookContext` 不可用;省略 `children` 时各层包裹空内容 | +| 注册 | 与 `list` 相同:必填 `id`,可选 `order`、`priority`;同 `id` 同 `priority` 抛错,不同 `priority` 遮蔽 | +| 嵌套顺序 | 依次按 `order`、`priority`、注册顺序升序,最小者在最外层 | +| 层的 props | 每层收到同一份 owner props,加上框架注入的 `children`(内一层,最内层是宿主内容);`children` 不属于 owner props,类型由 `HocShare` 提供,owner props 不得使用这个名字 | +| 层的义务 | 恰好渲染一次 `children`,自身不产生盒子;不能修改传给内层的 props | +| 无 entry | 原样渲染 `children` | +| 层崩溃 | 每层有独立 error boundary;崩溃时该层渲染自己的 `children` 并 abdicate,下一次渲染不再包含该层 | +| 内容出错 | 被包裹内容抛出的错误以原始错误穿过所有层,不记到任何层名下 | +| 重新挂载 | 增减一层会让其内部内容重新挂载;`session` 或 `session-maybe` 作用域的层在 Session binding 变化时也会让内部内容重新挂载 | + +### 实现 + +| 包 | 实现 | +|---|---| +| `ui-slots` | `SlotKind` 包含 `'hoc'`;`HocRenderOpts`、`HocKeysOf`、`HocShare`;`RenderSlotFn` 为 hoc key 提供接受可选 `{ children }` 的重载;`KindOptions` 有 hoc 分支;`SlotCore.register` / `entriesOfSlot` 按 `id` 划分 hoc cell | +| `ui-renderer` | `HocOutlet` 在插槽 ledger 版本或声明变化时调用 `composeHocLayers`(`useMemo` 缓存在 outlet 实例上),把各层组合成一个嵌套渲染函数。每层是一个组件,在组合时固定按 scope 选定的 Entry(`StrictSessionEntry` / `SessionMaybeEntry` / `RootEntry`)、entry、slot inject 与崩溃上报,渲染时只接收 `owner` 与内一层。没有层时 outlet 直接返回宿主内容。`HocContentBoundary` 包住每层的内层内容,把其中的错误包装为 `HocContentError` 重新抛出;层的边界放行这种错误,`HocOutletBoundary` 再解开为原始错误。Entry 链以 JSX children 把 `children` 交给注册组件;非 hoc entry 收到 `undefined`。`SlotErrorBoundary` 接受 `crashed` 替换节点 | +| `scripts/gen-client-catalog.ts` | hoc 插槽的注册选项、渲染 `children` 的示例,以及 `wraps-owner-content` 替换风险 | + +### 首个使用者 + +| 位置 | 实现 | +|---|---| +| `ui-conversation` | `conversation.hero.frame`(hoc,root),owner props 为 `{ hero: boolean }`,在所有阶段包裹 `.scrollBody`,首页结束时输入框不重新挂载。首页阶段(`.root[data-phase='hero']`),绘制背景的 Conversation 根元素设置 `isolation: isolate`,这是 frame 契约的一部分:层中负 `z-index` 的装饰画在该背景之上、所有 Conversation 内容之下。Conversation 不为层做任何点击判定 | +| `ui-brand-official` | `PixelWhale` 是 frame 层:`display: contents` 外框上的 React `onClick` 在主按钮点击留白时播放,排除 portal 浮层(`currentTarget.contains(target)`)、控件(选择器)和结束外框内文字选择的点击。`hero` 为 true 时以负 `z-index` 绘制 canvas。帧图经包 asset 路由加载;渲染器返回 `{ play, dispose }` | + +## Alternatives considered + +- `onBlankClick` 订阅转发:Conversation 承担只为装饰插件服务的点击分类。 +- canvas 放底下、`.scrollBody` 设 `pointer-events: none`:首页溢出时空白处无法滚动。 +- 不设 `z-index` 的 canvas:不需要层叠上下文,但会画在 `z-index: 1` 的首页输入区以外的所有非定位 Conversation 内容之上(例如输入区接管面板),并依赖那条无关的规则。 +- 由层自己的盒子建立层叠上下文:层必须复刻 body 的 flex 列布局。 +- 单独的 `renderNestedSlot` / JSX 组件 ``:多一套绑定;`hookContext` 已证明可以按 key 约束 `renderSlot` 的第三个参数。 +- 按 ledger 版本预编译所有插槽类型:端到端测不出耗时差异,见 #7838。 + +## Consequences + +- 增减一层会让其内部所有内容重新挂载,包括首页的输入框。 +- 所有非 hoc 的注册组件都会收到 `children: undefined`,会覆盖同名的 owner prop。 +- 每层在组件树中多一个组件和两个 error boundary;有层的 hoc 插槽另多一个 outlet 边界。 +- outlet 的 `[data-slot]` 元素位于 owner 与内容之间,owner 侧选择器不能假定内容是直接子元素。 +- React 18 会在错误被重新抛出的位置重新采集组件栈,因此有层的 hoc 插槽之上的边界收到的内容错误,组件栈从 `HocOutletBoundary` 开始。 +- 层如果自己在 `children` 外包错误边界,捕获到的是内部的 `HocContentError`,而不是原始的内容错误。 +- frame 契约只在首页阶段成立:其他阶段根元素不参与层叠隔离,未经 portal 的 Conversation 浮层仍能盖住悬浮在中栏上方的 AppFrame 元素;首页阶段它们只在根元素内部比较层级。 +- `HocOutlet` 与 Factory 局部组件一样用 `useMemo` 保持层组件身份;如果 React 将来会丢弃 memo 缓存,两处都需要改成显式缓存,因为这些组件是其内部内容的重新挂载边界。 +- 其他 kind 仍以 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 为准;本 Note 新增第五种。 diff --git a/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.i18n.yaml b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.i18n.yaml new file mode 100644 index 0000000000..1674bd7b72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-04-unprefixed-experimental-package-names.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md +/agent-note-name-new-experimental-packages-without-the-experimental-prefix: + en: b84bbfa015f35105 + zh: dff2c32694314996 +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/problem: + en: 84638e34c7b1d291 + zh: 0c3928b3424db016 +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/decision: + en: 0f13619da3b05747 + zh: fac563c2a8b61270 +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/alternatives-considered: + en: 1c6a08a813dcb3a7 + zh: 16bf31184377c80b +/agent-note-name-new-experimental-packages-without-the-experimental-prefix/consequences: + en: 2b536c42672e8850 + zh: f6a551964855308e diff --git a/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md new file mode 100644 index 0000000000..e6d48e0cc1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.md @@ -0,0 +1,29 @@ +# Agent Note: Name new experimental packages without the experimental prefix + +Status: implemented + +English | [中文](2026-11-04-unprefixed-experimental-package-names.zh.md) + +## Problem + +An experimental package named `@deepseek-ai/dsh-experimental-` must be renamed when it moves to a product group. The rename changes every import, dependency entry, module declaration, and profile file that names the package, so promotion carries a migration that is unrelated to the capability's behavior. + +## Decision + +New experimental packages use `@deepseek-ai/dsh-` and never the `experimental` prefix. Each one is declared in `EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS` in the [experimental package policy](../../../../scripts/experimental-package-policy.ts), because isolation checks and plugin-record ownership recognize an unprefixed experimental name only through that map. Promotion moves the package and removes its map entry; its npm name stays the same. + +Existing packages with the prefix are listed in `LEGACY_PREFIXED_EXPERIMENTAL_PACKAGE_DIRECTORIES`. `pnpm run constraints` rejects the prefix in any other directory and rejects a listed directory that no longer holds a prefixed package, so the list can only shrink. A legacy package is renamed when it is promoted; renaming it earlier needs the release and migration evidence that the [plugin identity decision](2026-10-06-web-desktop-plugin-identity-compatibility.md) requires. + +This decision replaces the deferred cleanup that the plugin identity decision recorded, which gave every experimental package the prefix. The [package rules](../../../../packages/experimental/AGENTS.md) state the naming rule for authors. + +## Alternatives considered + +**Keep the prefix as the default.** The name shows experimental status, but each promotion then renames the package and migrates its consumers and installed profiles. + +**Rename the legacy packages now.** One rename removes the legacy list, but it changes published names that existing dependencies and profiles use, without a promotion that needs the change. + +**Document the rule without a gate.** A new package copied from a legacy one would keep the prefix with every check passing. + +## Consequences + +The unprefixed-name map grows with every new experimental package; the legacy list only shrinks. Directory placement and the map, not the npm name, identify an unprefixed package as experimental, and an unprefixed name does not grant admission to the default product. diff --git a/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.zh.md b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.zh.md new file mode 100644 index 0000000000..f0ea98f897 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-04-unprefixed-experimental-package-names.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 新实验包命名不使用 experimental 前缀 + +Status: implemented + +[English](2026-11-04-unprefixed-experimental-package-names.md) | 中文 + +## 问题 + +名为 `@deepseek-ai/dsh-experimental-` 的实验包迁入产品分组时必须重命名。重命名会改变引用该包名的每个导入、依赖项、模块声明和 profile 文件,因此晋升会附带一次与能力行为无关的迁移。 + +## 决策 + +新实验包使用 `@deepseek-ai/dsh-`,不使用 `experimental` 前缀。每个新实验包都在[实验包命名策略](../../../../scripts/experimental-package-policy.ts)的 `EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS` 中声明,因为隔离检查和插件记录归属只通过这个映射识别不带前缀的实验包名。晋升时移动该包并删除其映射条目;其 npm 名称保持不变。 + +带前缀的既有包列在 `LEGACY_PREFIXED_EXPERIMENTAL_PACKAGE_DIRECTORIES` 中。`pnpm run constraints` 拒绝其他目录中出现该前缀,也拒绝已列出但不再包含带前缀包的目录,因此该清单只能缩减。遗留包在晋升时重命名;提前重命名需要[插件身份决策](2026-10-06-web-desktop-plugin-identity-compatibility.zh.md)要求的发布与迁移依据。 + +本决策取代插件身份决策记录的暂缓清理,该清理为每个实验包加上前缀。[包规则](../../../../packages/experimental/AGENTS.md)向作者说明命名规则。 + +## 考虑过的替代方案 + +**默认保留前缀。** 名称能体现实验状态,但每次晋升都要重命名该包,并迁移其消费方和已安装 profile。 + +**现在重命名遗留包。** 一次重命名即可删除遗留清单,但会在没有晋升需要的情况下改变既有依赖和 profile 使用的已发布名称。 + +**只记录规则,不设门禁。** 从遗留包复制的新包会保留前缀,而所有检查仍会通过。 + +## 后果 + +不带前缀包名映射随每个新实验包增长;遗留清单只会缩减。识别不带前缀的包是否为实验包依据的是目录位置和该映射,而不是 npm 名称;不带前缀的名称也不因此获准进入默认产品。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml deleted file mode 100644 index e17980cb32..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-17-multiline-display-dollar-math.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md -/agent-note-multi-line-display-dollar-math: - en: 8f57c77aa43fbd6c - zh: ef5192184e4923d5 -/agent-note-multi-line-display-dollar-math/problem: - en: e4b73644a982844c - zh: 48f86358242d6ccc -/agent-note-multi-line-display-dollar-math/decision: - en: d6b136b1f62285b7 - zh: aba323bfdb800162 -/agent-note-multi-line-display-dollar-math/alternatives-considered: - en: e0833b5674e648e7 - zh: b0472020b07099b9 -/agent-note-multi-line-display-dollar-math/consequences: - en: 70f16597bd640e93 - zh: 0e0c9005f331e514 -/agent-note-multi-line-display-dollar-math/testing: - en: f17a1453df91ce88 - zh: 4f15be0139e8b694 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md deleted file mode 100644 index 0362895314..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md +++ /dev/null @@ -1,66 +0,0 @@ -# Agent Note: Multi-line display-dollar math - -Status: implemented - -English | [中文](2026-09-17-multiline-display-dollar-math.zh.md) - -## Problem - -Authors and models write `$$` display math in three shapes. The settled grammar rendered two of them: `$$math$$` on one line, and an opening `$$` alone on its line closed by `$$` alone on its line. The third shape — math on the opening line, a closing `$$` at the end of the last math line — did not: - -```markdown -$$f(x)=x -\text{continued}$$ -``` - -`micromark-extension-math` treats text after an opening `$$` as fence meta and accepts a closing fence only on a line whose content is exactly `$$` (after optional whitespace). The [markdown compatibility layer](../../archived/feature/2026-07-23-web-assistant-markdown.md) claimed only the same-line shape, and the settled renderer passes `node.value` to KaTeX while ignoring `node.meta`. The block therefore never closed: it consumed the document text that followed, `value` retained the literal `$$`, KaTeX raised `Can't use function '$' in math mode`, and [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)'s error arm painted the TeX source plus the swallowed prose in red. - -In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a single error span, and six note files lost every line after such a block (up to 500 lines) to one. A second defect shared the mechanism: when a later lone `$$` did close the flow, the opening line had already been assigned to `meta`, so the renderer silently dropped the first line of the formula. - -## Decision - -[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node. - -A `$$` sequence that fails to close ends the candidate: immediately on a later line, and at that line's ending on the opening line (`$$x$$ trailing` fails there). Within the opening line the scan continues, so a later `$$` there still closes (`$$\verb*a$$a$$`). Scanning past either point would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. That state and its gate on the closing check belong to the dollar flow. The characters right after `\verb` or `\verb*` are that command's delimiter rather than a fence, so the delimiter and, when the region is empty, the `$` closing it are content; that tail is cleared at a line ending and after an escaped backslash (`\\verb` is a line break followed by letters), and a comment holds no commands. Otherwise verbatim regions are not parsed, which keeps `\verb`-bearing formulas on the same value path as every other input. - -Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there. - -[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns inline `$…$`. - -## Alternatives considered - -**Keep the compatibility layer at same-line `$$…$$`.** The multi-line shape is what GitHub, KaTeX's auto-render, VS Code, and Typora accept, and the form models emit for multi-case formulas; leaving it unparsed means a red error span in place of the formula plus the rest of the document. - -**Repair the parsed tree afterwards.** The flow consumes the swallowed text into `mathFlowValue`, where it is indistinguishable from math source; `meta` and `value` cannot be reassembled into the intended block, and the following blocks were never parsed at all. - -**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it. - -**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`. - -**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character. - -**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide. - -**Apply the interruption guard to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and an unconditional guard dropped their multi-line blocks (`Prose line` followed by `\[`). - -## Consequences - -The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath$$` parses as a formula whose value is `asciimath`, which upstream's flow treats as fence meta and drops. That spelling has no user in this repository, and the renderer never read `meta` anyway. - -Six inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after declines the block: immediately on a later line, and at that line's ending on the opening line. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF. - -The fourth is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block. - -The fifth follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node. - -The sixth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new. - -One further limitation is not a fallback: the flow reads the six characters before a fence and does not parse verbatim regions, so a fence inside an unterminated region (`\verb|$$`) closes the block there, and a region whose text ends in `\verb` (`\verb**\verb*$$`) has its fence read as that text's delimiter. The six characters before either fence are locally identical, so no rule that reads only that tail separates them. - -Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design. - -## Testing - -`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Further cases pin one condition each: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment, a `%` the fence follows immediately or after whitespace, a second `%` inside an open comment, a `%` inside `\verb|…|`, the delimiter right after `\verb` and `\verb*` together with its empty region, an escaped backslash before `verb`, a verb tail that crosses no line ending, a comment ending in `verb`, a later-line trailing fence whose following prose upstream takes, a failed fence inside a comment, and bounded-work cases for repeated candidates with that backslash, without it, and ending in a verb tail. - -The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md deleted file mode 100644 index ed0ef3f46a..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md +++ /dev/null @@ -1,66 +0,0 @@ -# Agent Note: Multi-line display-dollar math - -Status: implemented - -[English](2026-09-17-multiline-display-dollar-math.md) | 中文 - -## Problem - -作者和模型写 `$$` 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 `$$math$$`,以及 `$$` 独占一行、由独占一行的 `$$` 闭合。第三种——开围栏同一行就带公式内容,闭合 `$$` 落在最后一行公式行行尾——不渲染: - -```markdown -$$f(x)=x -\text{continued}$$ -``` - -`micromark-extension-math` 把开围栏 `$$` 之后的同一行文本当作围栏 meta,并且只接受内容恰为 `$$`(后接可选空白)的行作为闭合围栏。[markdown 兼容层](../../archived/feature/2026-07-23-web-assistant-markdown.md)只认同一行开闭这一种形状,而定稿渲染器把 `node.value` 交给 KaTeX,不读 `node.meta`。于是该块始终不闭合:它吞掉后续正文,`value` 里残留字面 `$$`,KaTeX 抛出 `Can't use function '$' in math mode`,[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx) 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。 - -在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 `$$` 恰好闭合了这个流时,开围栏那一行已经归给 `meta`,渲染器于是静默丢掉公式的第一行。 - -## Decision - -[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。 - -闭合失败的 `$$` 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束(`$$x$$ trailing` 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 `$$` 仍能闭合(`$$\verb*a$$a$$`)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。`\verb` 或 `\verb*` 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 `$` 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(`\\verb` 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 `\verb` 的公式与其它输入走同一条 value 路径。 - -它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。 - -[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。 - -## Alternatives considered - -**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。 - -**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。 - -**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。 - -**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。 - -**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。 - -**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。 - -**把打断守卫也用在反斜杠构造上。** 这个守卫是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,无条件套用会丢掉它们的跨行块(`Prose line` 后接 `\[`)。 - -## Consequences - -三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath$$` 会解析成 value 为 `asciimath` 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。 - -有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。「后面不是行尾」的 `$$` 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。 - -第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。 - -第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。 - -第六种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。 - -另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(`\verb|$$`)会在那里闭合块,而内容以文本 `\verb` 结尾的区域(`\verb**\verb*$$`)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。 - -重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。 - -## Testing - -`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 `%`、已开启注释内的第二个 `%`、`\verb|…|` 内的 `%`、`\verb` 与 `\verb*` 之后的分隔符及其空区域、`verb` 之前的转义反斜杠、不跨行的 verb 尾部、以 `verb` 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。 - -DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml index 8b8bba1309..bbc63b3c3b 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml @@ -12,8 +12,8 @@ en: 47b44ed9313c2ccf zh: 7ad254f8ef88351d /agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/alternatives-considered: - en: d52fa1af379dea5b - zh: 4e404994f7a607dc + en: 3153c1e7563a3357 + zh: 9841db2b0156075b /agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/verification: en: be076216381ea64d zh: 2da8d9819c20e2fe diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md index cd800e94fe..8a25a23102 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md @@ -29,7 +29,7 @@ The sentence stays one literal line. Rewording `the next result starts a new tur **Move the guidance into the tool description.** The paragraph is assembled from the set of visible delegation tools and lists their names; the tool description describes one tool. Moving the sentence changes ownership without changing the behavior it prevents. -**Restore every prompt phrase reverted together with this sentence.** The read guidance and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph. +**Restore every prompt phrase reverted together with this sentence.** The read guidance ([2026-10-26 note](2026-10-26-read-guidance-line-ranges.md)) and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md index b11656473a..ba1c5cbc6a 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md @@ -29,7 +29,7 @@ Status: implemented **把说明移进工具描述。** 这段指导按可见委派工具集合组装并列出工具名,工具描述只描述单个工具。移动这句会改变归属,但不改变它阻止的行为。 -**把与这句一起被回退的所有提示词都恢复。** read 指导与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。 +**把与这句一起被回退的所有提示词都恢复。** read 指导([2026-10-26 note](2026-10-26-read-guidance-line-ranges.zh.md))与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。 ## 验证 diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml new file mode 100644 index 0000000000..86e21bee13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-26-read-guidance-line-ranges.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md +/agent-note-read-guidance-names-shell-readers-and-line-ranges: + en: 1926710614598539 + zh: b0106d4a6a2d648d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/problem: + en: 4189d516419d8d12 + zh: 30d8496064dd44e3 +/agent-note-read-guidance-names-shell-readers-and-line-ranges/decision: + en: 34f18307f7ad87af + zh: 21c466c6ce6c598d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/alternatives-considered: + en: aea7ffb1159ab81a + zh: 4842ca79dc30654d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/verification: + en: cbc16bfba4abe862 + zh: 9a71e6c020c01f69 +/agent-note-read-guidance-names-shell-readers-and-line-ranges/consequences: + en: cf55161ea197a7a9 + zh: fa10ca413f8fd726 diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md new file mode 100644 index 0000000000..60ad77abee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md @@ -0,0 +1,35 @@ +# Agent Note: Read guidance names shell readers and line ranges + +Status: implemented + +English | [中文](2026-10-26-read-guidance-line-ranges.zh.md) + +## Problem + +The read guidance named only `cat` as the shell command the read tool replaces, and described `offset`/`limit` as continuing a large file rather than reading a line range. #6057 recorded a long Session reading ranges with `sed -n X,Yp` 60 times. #6054 added `sed`, `head`, `tail`, and the line-range wording; a later prompt revert removed them together with unrelated changes. + +The 2026-10-23 evaluation of the SDK profile (native tools, six benchmarks) measured this sentence as the only change: Pass@1 mean −0.03pp and Pass@3 mean +0.70pp over the five benchmarks that report Pass@3 (ProgramBench-New has none) versus the base commit, with mixed step and token deltas (ProgramBench-New −15.5 steps, Terminal-Bench-v4 +13.5 steps). The PTC mode runs did not include this variant; the section renders in every request where `read` is visible. + +## Decision + +Restore the #6054 wording, byte-identical: `Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` The read tool and its guidance are owned by `@deepseek-ai/dsh-tool-fs`; write and edit guidance are unchanged. + +## Alternatives considered + +**Rely on the read tool schema.** The schema already documents `offset` and `limit`, but #6057 shows the model chose `sed` while that schema existed; the guidance is the only model-visible place that names `sed`, `head`, and `tail`. + +**Restore every prompt phrase reverted together with this sentence.** The subagent end-turn sentence, the `run_code` argument order, the PTC syntax ownership, and the SDK persona are separate decisions; the first is already restored ([subagent end-turn note](2026-10-21-subagent-end-turn-guidance.md)), and the others carry their own evidence. + +**Say "line range" without naming shell commands.** The observed failure was choosing shell commands, so the sentence names them. + +## Verification + +- `packages/fs/tool-fs/tests/tools.spec.ts` asserts the rendered guidance text. +- Every recorded `system-prompt*.expected.md` sidecar that renders the read guidance carries the restored sentence: `test:snapshot` replays the session and SDK set, and `pnpm run test:web:ci` replays the `snapshots/web` set (the `node 24 / snapshots and artifacts` lane runs both). Two pinned historical sidecars (`web/cordis-tool-round`, `web/schedule-catalog`) retain their recorded text. +- The evaluation above measured this single change on the native SDK profile. + +## Consequences + +- Every request with the read tool carries three more command names and a line-range clause, about 10 tokens. +- The model is directed to the read tool for line ranges instead of `cat`, `sed`, `head`, or `tail`. +- The benchmark measured this sentence as neutral; keeping it rests on the #6057 production observation, not on a measured quality gain. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md new file mode 100644 index 0000000000..25487ea733 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md @@ -0,0 +1,35 @@ +# Agent Note: read 指导点名 shell 读取命令与行区间 + +Status: implemented + +[English](2026-10-26-read-guidance-line-ranges.md) | 中文 + +## 问题 + +read 指导只把 `cat` 点名为 read 工具替代的 shell 命令,并把 `offset`/`limit` 描述成继续读大文件,而不是读行区间。#6057 记录了一个长 Session 用 `sed -n X,Yp` 读行区间 60 次。#6054 增加了 `sed`、`head`、`tail` 和行区间措辞;后来的一次提示词回退把它们和不相关的改动一起删掉了。 + +2026-10-23 对 SDK profile(native 工具、六个 benchmark)的评测里,这句是唯一改动:相对 base commit,Pass@1 均值 −0.03pp、Pass@3 均值 +0.70pp(该均值取报告 Pass@3 的五个 benchmark;ProgramBench-New 没有 Pass@3),步数与 token 变化方向不一(ProgramBench-New −15.5 步、Terminal-Bench-v4 +13.5 步)。PTC 模式的运行没有包含这个变体;只要 `read` 可见,这段就会进入每个请求。 + +## 决策 + +逐字恢复 #6054 的措辞:`Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` read 工具及其指导归 `@deepseek-ai/dsh-tool-fs` 所有;write 与 edit 的指导不变。 + +## 考虑过的替代方案 + +**只依赖 read 工具 schema。** schema 已经写明 `offset` 与 `limit`,但 #6057 显示在那个 schema 存在时模型仍选择 `sed`;指导是唯一点名 `sed`、`head`、`tail` 的模型可见位置。 + +**把与这句一起被回退的所有提示词都恢复。** subagent 结束本轮句、`run_code` 参数顺序、PTC 语法归属、SDK persona 是各自独立的决定;第一项已经恢复([subagent 结束本轮 note](2026-10-21-subagent-end-turn-guidance.zh.md)),其余各有自己的证据。 + +**只说“行区间”,不点名 shell 命令。** 观测到的失败就是模型选择 shell 命令,所以这句点名它们。 + +## 验证 + +- `packages/fs/tool-fs/tests/tools.spec.ts` 断言渲染出的指导文本。 +- 所有渲染 read 指导的 `system-prompt*.expected.md` sidecar 都带有恢复后的句子:`test:snapshot` 回放 session 与 SDK 集合,`pnpm run test:web:ci` 回放 `snapshots/web` 集合(`node 24 / snapshots and artifacts` lane 两条都跑)。两个显式 pin 的历史 sidecar(`web/cordis-tool-round`、`web/schedule-catalog`)保留录制文本。 +- 上面的评测在 native SDK profile 上只改了这一句进行测量。 + +## 影响 + +- 只要 read 工具存在,每个请求多三个命令名和一句行区间说明,约 10 token。 +- 模型读行区间时被引导到 read 工具,而不是 `cat`、`sed`、`head`、`tail`。 +- benchmark 上这句测得为中性;保留它依据的是 #6057 的生产观测,而不是测到的质量收益。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.i18n.yaml new file mode 100644 index 0000000000..99a0eb8afa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-28-forge-git-session-permissions.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md +/agent-note-session-permissions-for-forge-git-queries: + en: a06c42342d1f0bad + zh: 88b063883336cb0b +/agent-note-session-permissions-for-forge-git-queries/problem: + en: ee1e5ba8190a9419 + zh: 88d6c35a3a400eb0 +/agent-note-session-permissions-for-forge-git-queries/decision: + en: 77d7706b2577e552 + zh: a4538a19656d5945 +/agent-note-session-permissions-for-forge-git-queries/alternatives-considered: + en: 69f0bbbc412a546f + zh: 9527e87b5adfc7c3 +/agent-note-session-permissions-for-forge-git-queries/consequences: + en: acf5249a959ac14c + zh: 268fc51ec3278dcb diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md new file mode 100644 index 0000000000..fd753156d0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md @@ -0,0 +1,31 @@ +# Agent Note: Session permissions for Forge Git queries + +Status: implemented + +English | [中文](2026-10-28-forge-git-session-permissions.zh.md) + +## Problem + +The [Forge panel](../feature/2026-10-09-forge-panel.md) forces read-only confinement even when a Session grants full access. On hosts without a usable sandbox, its checkout and local changes remain unavailable while the Session's shell works. Background queries also reach repository-configured executable helpers through working-tree comparisons. + +## Decision + +Git inspection uses the Session's resolved sandbox mode and subprocess provider. Full access requires no sandbox backend; confined modes retain their enforcement and never retry unconfined. The Session remains the authority for filesystem permissions, including workspace-write access. Query commands do not request repository mutations. + +Local patches and statistics apply command-local Git configuration to disable clean/process filters without changing user configuration. Environment entries preserve subsection names containing equals signs. Existing hook, fsmonitor, external-diff, textconv, optional-lock, and lazy-fetch restrictions remain; submodule queries show commit changes without scanning internal dirty files. Configuration read failure stops the comparison; the changes view reports the filter configuration failure separately from oversized diffs and offers a retry. These restrictions follow the informational-diff approach in Codex while retaining DSH's execution-world and cancellation ownership. + +## Alternatives considered + +**Force read-only for every query.** This makes an explicitly unconfined Session depend on an unavailable backend. Using the Session mode keeps tool and panel permission behavior consistent. + +**Retry without confinement after failure.** This would silently grant authority that a restricted Session does not have. + +**Run configured content converters.** They can execute programs and write files during automatic refresh. Disabling them trades converted-content equivalence for predictable query behavior. + +## Consequences + +Operating-system write protection follows the Session mode rather than a separate panel policy. With clean/process filters disabled, comparisons still apply Git's built-in newline, ident, and encoding conversions: materialized LFS and other filtered files can appear changed even when ordinary Git considers them clean. Pointer-only checkouts compare normally. Uncommitted submodule contents are omitted. The original Forge note remains active for panel ownership and worktree lifecycle; this note supersedes only its unconditional read-only policy. + +Filter overrides require Git 2.31 or newer; older Git can ignore them and execute configured helpers under Session permissions. Enumeration and diff run in separate processes, so a concurrently added driver can escape the overrides. These restrictions limit incidental helper execution; the Session sandbox determines the permissions of every process that runs. + +Real-repository tests cover permission modes, unavailable confinement, unchanged index/configuration, configured helper markers, submodule commits, filtered bytes, and failed filter enumeration. The Web replay covers permission changes through the picker and the resulting checkout and local-change view. diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.zh.md b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.zh.md new file mode 100644 index 0000000000..49ad8f7be5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Forge Git 查询的 Session 权限 + +Status: implemented + +[English](2026-10-28-forge-git-session-permissions.md) | 中文 + +## 问题 + +[Forge 面板](../feature/2026-10-09-forge-panel.zh.md)即使在 Session 授予完全权限时也强制使用只读沙箱。在没有可用沙箱的主机上,Session 的 shell 可以工作,检出信息与本地变更却持续不可用。后台查询还会通过工作区比较执行仓库配置的辅助程序。 + +## 决策 + +Git 检查使用 Session 解析出的沙箱模式与子进程提供方。完全权限无需沙箱后端;受限模式保留强制约束,绝不以无沙箱方式重试。Session 统一决定文件系统权限,包括工作区写入权限。查询命令不请求修改仓库。 + +本地补丁与统计应用命令级 Git 配置以禁用 clean/process filter,不修改用户配置。环境项保留包含等号的子节名称。现有钩子、fsmonitor、外部 diff、textconv、可选锁与延迟获取限制继续生效;子模块查询展示提交变化,不扫描内部脏文件。配置读取失败时停止比较;变更视图区分过滤器配置失败与 diff 过大,并提供重试。这些限制参考 Codex 的信息展示型 diff,同时保留 DSH 对执行环境与取消过程的管理。 + +## 考虑过的替代方案 + +**所有查询强制只读。** 这让已明确取消限制的 Session 依赖不可用的后端。使用 Session 模式使工具与面板的权限行为一致。 + +**失败后无沙箱重试。** 这会静默授予受限 Session 原本没有的权限。 + +**运行已配置的内容转换器。** 它们可能在自动刷新时执行程序并写入文件。禁用它们以放弃转换后内容等价性为代价,换取可预测的查询行为。 + +## 后果 + +操作系统的写入保护跟随 Session 模式,而非独立的面板策略。禁用 clean/process filter 后,比较仍应用 Git 内置的换行、ident 与编码转换:已物化的 LFS 文件及其他过滤文件可能显示为变更,即使普通 Git 认为它们未变。仅保留指针的检出可正常比较。子模块内部未提交内容不显示。原 Forge 记录仍保留面板所有权与工作树生命周期的决策;本记录仅替代其无条件只读策略。 + +过滤器覆盖需要 Git 2.31 或更新版本;旧 Git 可能忽略覆盖,按 Session 权限执行已配置的辅助程序。枚举与 diff 在独立进程中执行,因此并发新增的驱动可能绕过覆盖。这些限制减少附带的辅助程序执行;所有实际运行进程的权限由 Session 沙箱决定。 + +真实仓库测试覆盖权限模式、沙箱不可用、索引与配置不变、辅助程序标记、子模块提交、过滤文件字节和过滤器枚举失败。Web 回放覆盖通过选择器切换权限及相应的检出信息与本地变更视图。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.i18n.yaml new file mode 100644 index 0000000000..aaf821df79 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-28-plugin-install-failure-diagnostics.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog: + en: dc13db49685fee98 + zh: 65896465ceaba16a +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/problem: + en: 34c85dbc0fdaeeaf + zh: b11042edb1eb8e3b +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/decision: + en: fa3f10f9d7502077 + zh: c6b83153a0f5e324 +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/alternatives-considered: + en: 2616d060183a7a74 + zh: 15179fd42ea2b6dc +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/consequences: + en: 246d2a60f856c631 + zh: d7a552af3ad32b6f +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/related: + en: e0709383c16148e9 + zh: 00765408d0ad25f1 +/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/testing: + en: 336df2ffde183519 + zh: 86e62a8a052c1f91 diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md new file mode 100644 index 0000000000..89712bf9f7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md @@ -0,0 +1,47 @@ +# Agent Note: Install failures keep their diagnostics in the dialog + +Status: implemented + +English | [中文](2026-10-28-plugin-install-failure-diagnostics.zh.md) + +## Problem + +A failed plugin installation could show the generic sentence "Something went wrong during the install; the details say what" while its expanded details showed only "No output". The dialog rendered the live install-log stream and the failure kind, and the two Host diagnostics that explain such a failure never entered that stream. A pnpm run that printed nothing had its diagnostic (`result.shortMessage`, or `pnpm failed` as the fallback) written to the complete log and the returned result but never streamed, so the dialog's terminal stayed empty. The GitHub connection check that stops an installation before pnpm runs kept its captured output only in `packageResult.output`. The Client also ignored the settled result's `packageResult.logPath` and `stage`, so a failure with recorded diagnostics offered no path to the complete log and could not say that a finished installation failed while the bundle was being activated. + +## Decision + +**The install-log stream carries the Host diagnostic of every run the dialog shows.** A pnpm run that fails without printing anything streams the diagnostic the Host records for it, so the complete log and the dialog show the same text. The GitHub connection check reports the `git … ls-remote` command line it ran, with the user information of an http(s) repository URL removed; when its network failure or timeout stops the installation, `installBundle` emits that command, the captured output, and the failure's exit status as the installation's only install-log run under the caller's request id. A check failure the installation survives streams nothing. A timed-out check's Git returned no exit status, so that run settles without one; the returned result records the coerced status 1, because `PackageResult.exitCode` is a number, and `PackageResult.timedOut` states that the status is the one the failure is read as. The command line itself stays module-local: `installBundle` strips it off the plain `PackageResult` it returns, so no second wire field appears. + +**The failed screen keeps what the settled result reports, not only the live stream.** `InstallState` holds the settled `packageResult.logPath` — the run that settled the dialog, on the Host's filesystem — and the details name it as the complete log. `InstallState['failure'].stage` and `ChangeResult.enabled` add the sentence that the installation finished and enabling the bundle failed, only when that change did ask to enable it; an install that left activation to **Enable now** never claims it failed enabling. Details on a settled screen without a streamed run say the dialog received no terminal output, never that the run printed none, and a running install makes no such claim. The generic sentence names the failed install command, except when the failed step is applying an enabled bundle, where the enabling sentence stands alone. The answer and the log travel over separate transports, so a run whose first chunk arrives after the answer is kept in the settled dialog as a settled run rather than dropped or shown as running. + +No wire type, public export, or config key is added: `ChangeResult.packageResult.logPath`, `ChangeResult.stage`, `ChangeResult.enabled`, and `packageResult.timedOut` already carry these facts to every consumer. The check's command line is a module-local `GithubConnectionFailure` in `github-connection.ts`. + +## Alternatives considered + +**Have the Client synthesize a terminal run from `packageResult.output`.** Rejected: the install-log stream would stay incomplete for every other reader, including the agent tool and the complete Host log, and the dialog would carry two notions of a run — one streamed, one rebuilt from the result. + +**Render `packageResult.output` as a separate Host-diagnostic block.** Rejected: it duplicates the terminal surface with a second styling and copy path, and the failing check's command line would still be missing. + +**Add a failure-stage field to `ChangeResult`.** Rejected: `stage` already names installation and activation, and only activation needed its own sentence; a second stage vocabulary would make every consumer learn two fields for one fact. + +**Keep the generic sentence and rely on the details.** Rejected: the empty details panel this fixes was exactly what the sentence pointed at, so the sentence must not promise diagnostics the dialog does not show. + +**Report the check's coerced exit code in the streamed run too.** Rejected: the stream describes what the process did, and a Git the deadline killed returned no status; the result keeps the coerced status only because its exit code is a number, which the JSDoc states. + +## Consequences + +- A failed installation always offers something to act on: a pnpm diagnostic for a silent failure, the failing Git command and its output for a GitHub connection failure, the complete log of the settled run, and a sentence naming the failed step when an enabled installation could not be applied. +- The generic sentence becomes "The install command failed; the details say why" in every dictionary; the details' no-run note says the dialog received no terminal output. +- A GitHub connection failure reports `timedOut` and returns the coerced exit status 1, while its streamed run settles without an exit code; the displayed command line omits URL user information, and no `argv` field joins the Remote answer. +- The details' log path covers the run that settled the dialog and is a Host path, so earlier registry attempts and a remote browser may not reach it. +- No new configuration, wire field, or public package export. + +## Related + +[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) owns the install dialog's screens, the pre-install spec check, and the failure classification; this note extends its failed screen and install-log stream. + +## Testing + +- `packages/boot/plugin-manager/tests/operations.spec.ts` pins the no-output diagnostic stream; `github-connection.spec.ts` pins the recorded command line without URL user information and the timeout state, including the coerced status in the result; `manager.spec.ts` pins the single streamed check run under the request id, its absent exit code on timeout, that the returned `packageResult` carries no command line, and that a check failure pnpm may resolve streams nothing. +- `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` pins the settled log path, stage and enablement fact, the run kept when its first chunk arrives after the answer, and the reset on retry; `components.client.spec.tsx` pins the enabling sentence (only for an install that asked to enable), the complete-log line, and the settled no-output sentence. +- `apps/web/tests/plugin-install-github.e2e.ts` expands the details of a real GitHub connection failure and finds the Git command, its output, and the log path; `apps/web/tests/plugin-install-silent-failure.e2e.ts` fails a real installation through a package manager that prints nothing and finds the run's command line, its diagnostic, and the complete log in the details. diff --git a/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md new file mode 100644 index 0000000000..5c8220ed13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Install failures keep their diagnostics in the dialog + +Status: implemented + +[English](2026-10-28-plugin-install-failure-diagnostics.md) | 中文 + +## Problem + +一次插件安装失败时,界面可能显示通用的“安装过程中出错,原因见安装详情”,而展开后的详情只有“无输出”。对话框渲染的是实时的 install-log 流和失败 kind,而解释这类失败的 Host 诊断从未进入该流。打印了空输出的 pnpm 运行,其诊断(`result.shortMessage`,缺省为 `pnpm failed`)只写进完整日志和返回结果,从未被流式发送,因此对话框的终端一直是空的。在 pnpm 运行前就中止安装的 GitHub 连接检查,其捕获的输出只留在 `packageResult.output` 里。客户端同时也忽略了已结束结果中的 `packageResult.logPath` 和 `stage`,所以即使诊断已被记录,失败界面也没有指向完整日志的入口,也无法说明安装其实已经完成、失败发生在启用 bundle 时。 + +## Decision + +**install-log 流携带对话框所展示的每一次运行的 Host 诊断。** 没有打印任何内容就失败的 pnpm 运行,会把 Host 为其记录的那条诊断流式发送出来,于是完整日志和对话框显示同样的文本。GitHub 连接检查会报告它实际执行的 `git … ls-remote` 命令行,并去掉 http(s) 仓库 URL 中的用户信息;当它的网络失败或超时中止安装时,`installBundle` 把该命令、捕获的输出和这次失败的退出状态作为该次安装唯一的 install-log 运行、以调用方的 request id 发出。安装仍能继续进行的检查失败不发送任何内容。超时的检查里 Git 没有返回退出状态,所以该运行不带退出码结束;返回结果记录强制填入的状态 1,因为 `PackageResult.exitCode` 是数字,而 `PackageResult.timedOut` 说明该状态只是失败被读取成的状态。命令行本身仍只在模块内:`installBundle` 在返回的普通 `PackageResult` 上剥掉它,因此不会多出 wire 字段。 + +**失败界面保留已结束结果所报告的内容,而不只是实时流。** `InstallState` 持有已结束的 `packageResult.logPath`——结束对话框的那次运行,位于 Host 文件系统——详情把它标为完整日志。`InstallState['failure'].stage` 与 `ChangeResult.enabled` 补充一句话:安装已完成,启用 bundle 时失败;只有当这次变更确实要求启用时才出现,把启用留给**立即启用**的安装不会声称启用失败。已结束界面里没有任何流式运行的详情会说明对话框没有收到终端输出,而不会断言运行本身没有输出;安装进行中也不会这样说。通用提示句点明失败的是安装命令,但失败步骤是应用已启用的 bundle 时,只使用启用阶段的那句话。安装应答与日志走不同传输,因此首个 chunk 晚于应答到达的运行会作为已结束的运行保留在已结束的对话框里,既不会被丢弃,也不会显示成运行中。 + +不新增 wire 类型、公开导出或配置键:`ChangeResult.packageResult.logPath`、`ChangeResult.stage`、`ChangeResult.enabled` 和 `packageResult.timedOut` 本来就把这些事实带给每个消费方。检查的命令行是 `github-connection.ts` 内模块级的 `GithubConnectionFailure`。 + +## Alternatives considered + +**让客户端从 `packageResult.output` 合成一次终端运行。** 否决:对其他读取方(agent 工具和完整的 Host 日志)来说 install-log 流仍然不完整,而且对话框会出现两种“运行”的概念——一种来自流,一种从结果重建。 + +**把 `packageResult.output` 渲染成单独的 Host 诊断块。** 否决:它会以第二套样式和文案路径重复终端界面,而且失败检查的命令行仍然缺失。 + +**给 `ChangeResult` 新增失败阶段字段。** 否决:`stage` 已经区分安装和启用,而且只有启用需要自己的提示句;第二套阶段词汇会让每个消费方为一个事实学两个字段。 + +**保留通用提示句、只依赖详情。** 否决:这次要修的正是被该提示句指向的空详情面板,因此提示句不能承诺对话框并不显示的诊断。 + +**在流式运行里也报告检查被强制的退出码。** 否决:流描述的是进程实际做了什么,而被期限终止的 Git 没有返回任何状态;结果保留强制状态只是因为它的退出码字段是数字,JSDoc 已写明这一点。 + +## Consequences + +- 安装失败后总有可行动的信息:静默失败时的 pnpm 诊断、GitHub 连接失败时出错的 Git 命令及其输出、结束对话框那次运行的完整日志,以及已启用的安装在应用阶段失败时点名失败步骤的一句话。 +- 通用提示句在所有词典中变为“安装命令失败,原因见安装详情”;详情里的无运行提示改为说明对话框没有收到终端输出。 +- GitHub 连接失败会报告 `timedOut` 并返回强制状态 1,而它的流式运行不带退出码结束;展示的命令行去掉 URL 用户信息,Remote 应答里也不会多出 `argv` 字段。 +- 详情里的日志路径只覆盖结束对话框的那次运行,且是 Host 路径,因此更早的换源尝试与远端浏览器可能都够不到它。 +- 不新增配置、wire 字段或公开包导出。 + +## Related + +[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.zh.md) 拥有安装对话框的各个界面、安装前的 spec 检查和失败分类;本记录扩展了它的失败界面和 install-log 流。 + +## Testing + +- `packages/boot/plugin-manager/tests/operations.spec.ts` 钉住无输出诊断的流式发送;`github-connection.spec.ts` 钉住去掉 URL 用户信息的记录命令行与超时状态(含结果里的强制状态);`manager.spec.ts` 钉住以 request id 发送的唯一检查运行、超时下缺失的退出码、返回的 `packageResult` 不含命令行,以及 pnpm 可能自行解决的检查失败不发送任何运行。 +- `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` 钉住已结束的日志路径、stage 与启用事实、首个 chunk 晚于应答到达时保留的运行,以及重试时的重置;`components.client.spec.tsx` 钉住启用阶段的提示句(只对要求启用的安装)、完整日志行,以及已结束界面的无输出提示句。 +- `apps/web/tests/plugin-install-github.e2e.ts` 展开一次真实 GitHub 连接失败的详情,找到 Git 命令、它的输出和日志路径;`apps/web/tests/plugin-install-silent-failure.e2e.ts` 让一次真实安装通过不打印任何内容的包管理器失败,并在详情中找到该运行的命令行、诊断和完整日志。 diff --git a/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml new file mode 100644 index 0000000000..618dcc06e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-11-01-locked-python-runtime-deploy.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md +/agent-note-locked-python-runtime-deployment: + en: 6a06f1078957df51 + zh: d7c2146381443ced +/agent-note-locked-python-runtime-deployment/problem: + en: 2293006c8bae6a1a + zh: 10670f2d703777dd +/agent-note-locked-python-runtime-deployment/decision: + en: 223079c8e1e3a44e + zh: c906266cb0b231bc +/agent-note-locked-python-runtime-deployment/alternatives-considered: + en: 978c94c7013056a5 + zh: afae764120582b6a +/agent-note-locked-python-runtime-deployment/testing: + en: d862dd609725c686 + zh: 8889c5d9002bbc68 +/agent-note-locked-python-runtime-deployment/consequences: + en: 41827db476b30a43 + zh: 5a7652f310a69909 diff --git a/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md new file mode 100644 index 0000000000..a1763148c8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.md @@ -0,0 +1,33 @@ +# Agent Note: Locked Python runtime deployment + +Status: implemented + +English | [中文](2026-11-01-locked-python-runtime-deploy.zh.md) + +## Problem + +Python executable packaging needs the same dependency versions and integrity records as the workspace lockfile. Legacy hoisted deployment ignores that lockfile and resolves compatible registry versions again: a manifest permitting `tslib@^2.8.1` can install `2.9.1` while the lockfile records `2.8.1`. Office archive preparation then rejects the missing locked integrity. A successful frozen workspace install does not constrain that later legacy deployment. + +## Decision + +The Python builder uses pnpm's default deployment, which derives a dedicated production lockfile from the workspace lockfile and installs it frozen. This replaces only the deployment strategy in the [single-executable distribution decision](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md); its runtime architecture and carrier layout remain active. + +Deployment retains the source lockfile's peer settings, including `autoInstallPeers: true`. Frozen installation preserves already resolved versions and peer relationships instead of resolving new registry peers. Forcing `autoInstallPeers: false` conflicts with the derived lockfile settings and fails frozen installation. The resulting tree need not equal the legacy tree resolved with peers disabled; selected dependencies must come from the locked graph. `verify-runtime-closure.ts` independently requires explicit workspace peers and preset plugins before deployment, and `verifyStagedDependencies` rejects missing direct dependency directories after materialization and deduplication. + +[`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) translates reviewed local `file:` approvals to the exact absolute file URLs used in the derived lockfile. Approval, denial, pending decisions, and strict build validation remain unchanged. Default deployment executes approved installation scripts; their generated files can enter the staged payload. Workspace policy and the source lockfile remain unchanged. + +## Alternatives considered + +**Keep legacy deployment and add missing integrity records.** This would accept versions the source lockfile did not select and repeat the failure after another compatible release. + +**Disable automatic peer installation during default deployment.** This conflicts with the source lockfile settings. Re-resolving a separate graph would give up the source lockfile's authority. + +**Disable deployment scripts or approve local packages by name.** Skipping scripts omits required postinstall preparation. Name-only approvals do not authorize non-semver local file dependencies; exact identity translation preserves the existing review decisions. + +## Testing + +A real pnpm comparison with the same manifest and lockfile installs `tslib@2.9.1` through legacy deployment and retains `2.8.1` through default deployment. The Linux executable and locally built installed-wheel SDK smokes pass. Focused tests reject missing dependency directories and deployment flag regressions, preserve approval states, and exercise both metadata path bases. Cross-platform packaging and installed-wheel validation remain owned by CI. + +## Consequences + +Runtime versions remain traceable to the workspace lockfile, and incomplete direct dependency trees fail before executable packaging. Installation script outputs and locked peer selections can differ from legacy deployment, so byte-identical payloads are not promised. SSH helper deployment has a separate closure manifest and native payload validation; its legacy deployment is outside this Python runtime repair. diff --git a/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md new file mode 100644 index 0000000000..8b24c383e7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-01-locked-python-runtime-deploy.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Python 运行时按锁文件部署 + +Status: implemented + +[English](2026-11-01-locked-python-runtime-deploy.md) | 中文 + +## 问题 + +Python 可执行文件打包需要使用与工作区锁文件相同的依赖版本和完整性记录。legacy hoisted 部署忽略该锁文件,重新解析兼容的注册表版本:允许 `tslib@^2.8.1` 的 manifest 可能安装 `2.9.1`,而锁文件记录的是 `2.8.1`。Office 归档准备随后因缺少锁定的完整性记录而拒绝构建。工作区的冻结安装成功,并不能约束后续的 legacy 部署。 + +## 决策 + +Python 构建器使用 pnpm 的默认部署,从工作区锁文件派生独立的生产锁文件,再冻结安装。本记录只替代[单文件可执行程序分发决策](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的部署策略;其中的运行时架构和载体布局继续有效。 + +部署保留源锁文件的对等依赖设置,包括 `autoInstallPeers: true`。冻结安装保留已解析的版本和对等依赖关系,不会重新解析新的注册表对等依赖。强制指定 `autoInstallPeers: false` 与派生锁文件的设置冲突,会使冻结安装失败。生成的依赖树不必与关闭对等依赖解析的 legacy 依赖树相同,但所选依赖必须来自锁定的依赖图。`verify-runtime-closure.ts` 独立要求在部署前显式声明工作区对等依赖及 preset 插件;`verifyStagedDependencies` 在链接实体化与去重后拒绝缺失的直接依赖目录。 + +[`scripts/python-runtime-deploy.pnpmfile.mjs`](../../../../scripts/python-runtime-deploy.pnpmfile.mjs) 将已审阅的本地 `file:` 审批转换为派生锁文件使用的精确绝对文件 URL。批准、拒绝、未决定状态和严格构建校验均保持不变。默认部署执行已批准的安装脚本,其生成的文件可能进入暂存载荷。工作区策略和源锁文件保持不变。 + +## 考虑过的替代方案 + +**保留 legacy 部署并补全缺失的完整性记录。** 这会接受源锁文件没有选择的版本,并在另一个兼容版本发布后重复出现故障。 + +**在默认部署中关闭对等依赖自动安装。** 这与源锁文件的设置冲突;重新解析另一套依赖图会放弃源锁文件的决定权。 + +**禁用部署脚本,或按名称批准本地包。** 跳过脚本会遗漏必需的 postinstall 准备。仅按名称批准不能授权非 semver 的本地文件依赖;转换精确标识可以保留原有审批决定。 + +## 测试 + +真实 pnpm 对照使用相同 manifest 和锁文件,legacy 部署安装 `tslib@2.9.1`,默认部署保留 `2.8.1`。Linux 可执行文件及本地构建并安装的 wheel SDK 冒烟测试通过。定向测试拒绝缺失的依赖目录和部署参数回退,保留审批状态,并覆盖两种 metadata 路径基准。跨平台打包及安装后 wheel 校验仍由 CI 负责。 + +## 影响 + +运行时版本可以追溯到工作区锁文件,不完整的直接依赖树会在可执行文件打包前失败。安装脚本产物和锁定的对等依赖选择可能与 legacy 部署不同,因此不承诺载荷逐字节相同。SSH helper 部署使用独立的闭包 manifest 和原生载荷校验,其 legacy 部署不属于本次 Python 运行时修复范围。 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 9c1ddab7b7..43557a7492 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -18,11 +18,11 @@ en: 4cd76a34f4712e1a zh: 303ef72a18644d7c /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-run-code-tool-and-the-dispatch-bridge: - en: 6a0e2698b082978e - zh: fbb03503520b30e9 + en: c3d079330748cd24 + zh: 4e2cc41d00227780 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/observability-tool-ptc-dispatch: - en: b8e4cf9433c7ee18 - zh: e5f4e8d7d03d3a7b + en: 5423da23d4e8d1d9 + zh: f1198d4f224f3fe6 /agent-note-ptc-mode-the-model-writes-typescript-against-the-tool-registry/decision/the-ptc-runtime-seam: en: 4c423f4e9cef6055 zh: 5c23a3637eb420c0 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index e99eccd65b..8e12a2db3e 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -56,13 +56,15 @@ Under `'ptc'` the registry owns `run_code` as a reserved presentation transport **Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel reference](../../../../packages/core/tools/README.md) owns the scheduler that replaced it. +**Tool-call allowance.** Counting attempts before validation and queuing bounds loops that repeatedly fail or enqueue work. The allowance scales with the resolved timeout and permits bursts; it does not enforce a rolling window. Exhaustion uses the run-scoped abort path so a program cannot catch a binding failure and keep running. Active calls are canceled, queued calls are abandoned, and settlement waits for cleanup. Completed effects remain; a new run receives a fresh allowance. The [executor README](../../../../packages/core/tool-executor-ptc/README.md#use-this-package) owns configuration and failure reporting. + **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). ### Observability: `tool/ptc-dispatch` Each sub-dispatch appends a log-only `tool/ptc-dispatch-start` event at pool entry and a `tool/ptc-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. -New sub-calls use `:ptc:` ids, numbered in submission order. All call ids are opaque to consumers: migration preserves every historical id byte-for-byte, including `:code:` substrings, so dispatch pairs, spill references, and other correlations remain intact. The bridge attributes forwarded image context to `{ kind: 'plugin', plugin: 'tools-ptc' }`. +New sub-calls use `:ptc:` ids, numbered by tool-call attempt. Invalid arguments consume a number without a dispatch event, so logged ids may have gaps. All call ids are opaque to consumers: migration preserves every historical id byte-for-byte, including `:code:` substrings, so dispatch pairs, spill references, and other correlations remain intact. The bridge attributes forwarded image context to `{ kind: 'plugin', plugin: 'tools-ptc' }`. The [V2-to-V3 PTC specification](../../../../packages/session/session-format-v2-to-v3/README.md#ptc-vocabulary) owns exact historical tag and attribution conversion; [native V3 admission](../../../../packages/session/session-format-v2-to-v3/README.md#native-v3-admission) owns predecessor-tag refusal. These are not runtime aliases: an opaque extension must not acquire PTC lifecycle meaning merely through a version change. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 71efafa7f2..813a795888 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -56,13 +56,15 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 **并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 约定相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 参考](../../../../packages/core/tools/README.zh.md) 负责。 +**工具调用额度。** 在验证和排队之前对尝试计数,可以限制重复失败或持续入队的循环。额度随最终超时时间缩放,允许集中调用,不实施滑动窗口限制。额度耗尽时使用运行级取消路径,使程序无法捕获绑定错误后继续运行。活动调用被取消,排队调用被放弃,结算会等待清理完成。已完成的操作仍然生效;新运行获得完整额度。[执行器 README](../../../../packages/core/tool-executor-ptc/README.zh.md#use-this-package) 负责配置与失败报告。 + **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.zh.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 ### 可观测性:`tool/ptc-dispatch` 每次子分发在进入分发池时追加一个仅日志的 `tool/ptc-dispatch-start` 事件,并以一个 `tool/ptc-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 -新子调用使用 `:ptc:` 标识,按提交顺序编号。消费者将所有 call id 视为不透明值:迁移逐字节保留每个历史标识,包括 `:code:` 子串,因此分发事件对、spill 引用及其他关联保持完整。桥接层将转发图片上下文的来源标记为 `{ kind: 'plugin', plugin: 'tools-ptc' }`。 +新子调用使用 `:ptc:` 标识,按工具调用尝试编号。无效参数会占用编号而不产生分发事件,因此日志中的编号可能有空缺。消费者将所有 call id 视为不透明值:迁移逐字节保留每个历史标识,包括 `:code:` 子串,因此分发事件对、spill 引用及其他关联保持完整。桥接层将转发图片上下文的来源标记为 `{ kind: 'plugin', plugin: 'tools-ptc' }`。 [V2 到 V3 PTC 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#ptc-vocabulary)负责精确的历史标签与归属转换;[原生 V3 准入](../../../../packages/session/session-format-v2-to-v3/README.zh.md#native-v3-admission)负责前代标签拒绝。这些不是运行时别名:不透明扩展不能仅因版本变化就获得 PTC 生命周期含义。 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 6566e1a57e..ef8bb48089 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -9,8 +9,8 @@ en: a72071eeedb77402 zh: 818d16ce429fd610 /agent-note-continuable-subagents/decision: - en: 3420195435e02a8f - zh: 6156eedb3c23d360 + en: 6ba1bf6184794aa8 + zh: 71b4e41556dafb93 /agent-note-continuable-subagents/decision/materialization-and-public-operations: en: c3a83ff748a02143 zh: 468bf62c8ddabf70 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index e76aae22fe..f5d178fec7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -18,6 +18,8 @@ Parent Agents need to send later work to the same live child without changing it ## Decision +Human control addressing and permissions are superseded by [target Session user controls](../architecture/2026-10-22-subagent-target-user-controls.md); model-message adjacency and parent-owned runtime restrictions are superseded by [tree-wide communication](../architecture/2026-10-16-tree-wide-agent-communication.md). The direct-parent authorization statements below do not govern those current operations. + The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. A continuable subagent has one durable Session and at most one process-local Activation: diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index ce543b4efa..44f51e1d9e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -18,6 +18,8 @@ parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 ## 决策 +人工控制的地址和权限由[目标 Session 用户控制](../architecture/2026-10-22-subagent-target-user-controls.zh.md)取代;模型消息的相邻关系限制和父级运行时所有权限制由[整树通信](../architecture/2026-10-16-tree-wide-agent-communication.zh.md)取代。下文的直接父级授权表述不约束这些当前操作。 + 共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 68ef489fe0..dd2778d632 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -6,35 +6,35 @@ en: c914d8148bfc4bf9 zh: a8115f87c0ac6cae /agent-note-durable-agent-teams-over-continuable-children/problem: - en: bab62798c2dc07b4 - zh: fe42c5abf9127d37 + en: 567b68ab8eb4b5b2 + zh: 31a40aa22821d63a /agent-note-durable-agent-teams-over-continuable-children/decision: - en: 35650a30529c01f8 - zh: 4f9ee01b568d2fed + en: affeddb8788ea73b + zh: 73615d3ccbd71871 /agent-note-durable-agent-teams-over-continuable-children/profile-delegation: - en: 4835fb8c0c0d8a0d - zh: 5f9438a0d58104c4 + en: f4c8d8760c7ebcf4 + zh: 32ccca202a7769ac /agent-note-durable-agent-teams-over-continuable-children/team-identity: - en: f82812d6ac0ac78d - zh: 7dd80a6ad13674ca + en: d79f4425f2c1929f + zh: 6586410db4a02cd1 /agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery: - en: 4345ac9a6ac7ad04 - zh: 1d19ecbb128a75a3 + en: 624e9d42af1af859 + zh: 020dcbb26211ea67 /agent-note-durable-agent-teams-over-continuable-children/mailbox-and-task-transactions: - en: bfc3063536837c0e - zh: 6b8e69ba85cc2bb1 + en: bfa07b5bc6354360 + zh: c82119c0a5ea7f01 /agent-note-durable-agent-teams-over-continuable-children/shared-checkout-boundary: - en: 34920da68b5763e0 - zh: 7604392594e4b8a8 + en: 384f75638b0f1fb6 + zh: b7ab1191784d3d27 /agent-note-durable-agent-teams-over-continuable-children/web-projection: - en: e6348054f024725b - zh: 8740636e99940308 + en: 1dc1386f5033e83b + zh: 533803da51f3716f /agent-note-durable-agent-teams-over-continuable-children/alternatives-considered: - en: 8cb2fd8839f5f8b7 - zh: e302ca03e569de65 + en: 087ffae4744a129b + zh: 842dc1ded7f8d9c3 /agent-note-durable-agent-teams-over-continuable-children/testing: - en: 092ac3c2b6f552aa - zh: c688c1bec5ad8a07 + en: 2ff7adaaaa65fad7 + zh: 9c7dd682abe988d1 /agent-note-durable-agent-teams-over-continuable-children/consequences: - en: da9cd22b1185a793 - zh: e20d7ef9226f237f + en: c071fa2ccdb911b5 + zh: 3b8cf0d3303a4d40 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index b93e2716f9..2056882e73 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -10,47 +10,41 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. -Agent Teams needs an explicit opt-in composition while its public contracts remain experimental. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. +Team collaboration requires explicit opt-in; [Session selection](../architecture/2026-10-20-session-team-mode.md) owns its granularity and default composition. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. ## Decision -Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`. +Every ordinary root and its descendants form the Team identified by the root `SessionId`; durable task state begins with the first task event. [Subagent teammates](../architecture/2026-10-15-agent-teams-as-subagents.md) owns the single agent identity. [Inbox-only waiting](../simplification/2026-10-29-inbox-only-agent-wait.md) keeps task-board changes independent of inbox waiting. An ordinary Session fork leads a new tree and excludes inherited Team authority. -The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The shared policy announces Team availability and describes coordination responsibilities. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion. +The implementation is split into `@deepseek-ai/dsh-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. The base bundle mounts both plugins; Team registers no tool that shares a name with the subagent controls. The shared policy announces Team availability and describes coordination responsibilities. Both packages belong to the [subagent family](../../../../packages/subagent/README.md); the root Session’s saved selection gates the task tools and policy. The [Session-mode upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) describes profile migration. The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit. ## Profile delegation -The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork’s own role. +The [base bundle](../../../../packages/bundle/base/README.md) inserts the Team rows and keeps the base delegation and control tools; teammates are started with those tools as described in [subagents](../architecture/2026-10-15-agent-teams-as-subagents.md). Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork's own role. ## Team identity -Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. +Shared system policy and tool schemas stay uniform across members; execution enforces role restrictions. Team tools resolve the caller’s Team and use Session ids as task owners. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. ## Provisioning and recovery -Creation first appends and flushes a `team/member` provisioning snapshot in the Lead Session, then starts the reserved continuable child through the selected fresh or fork provider. Failure before initial inbox acceptance appends a failed snapshot. Success flushes the child's accepted inbox item before appending active. Recovery recognizes that initial message while it is still pending or after it enters user-message history. Names are reserved by the first provisioning record and never reused, including after failure. Disposal closes admission, aborts and awaits admitted creation and send operations, then stops every live child recorded by the roster; a failed child remains cleanup-owned until its Activation exits, and cleanup rejection fails disposal. - -A root recovery reconciles an unterminated provisioning record against the child's independently persisted Session. Matching direct-parent and continuable descriptors plus a recorded initial user message prove successful admission and produce active; absence, corruption, mismatched provider/lineage, or a missing admitted message produces failed. The creator re-reads the terminal phase under the same Lead-log serializer; if recovery marked failed while creation succeeded, it drains the child and reports a provisioning conflict instead of retaining an orphan. This avoids reconstructing an initial prompt that was never retained in the Team log and contains plugin-reload races. - -Fresh children have no inherited conversation. Fork children capture the Lead's completed-turn prefix once and retain it as their own durable seed. The current delegation turn remains excluded, matching the existing fork provider contract. +Teammate creation and cold resume belong to the subagent runtime ([subagent teammates](../architecture/2026-10-15-agent-teams-as-subagents.md)). Historical `team/member` records remain readable with their phases and descriptions; Team writes no new membership records. ## Mailbox and task transactions -The historical Lead-log mailbox remains a readable format: queued and delivered records keep their stable identity and sender, and Team payload validation still applies to them. Team no longer delivers or acknowledges them; new sends follow the [direct inbox decision](../simplification/2026-09-26-team-direct-inbox.md). +The historical Lead-log mailbox remains readable in original Session logs. The [task-only projection decision](../simplification/2026-10-29-team-task-only-projection.md) supersedes mailbox projection, checkpoint retention, and Team payload validation. Historical queued messages are never delivered or acknowledged. -Unknown mailbox content stays as decoded JSON during event projection and checkpoint admission. Validation inspects its type without rebuilding its fields, because generic object parsing can omit a valid own `__proto__` key. Locally declared content variants undergo structural validation; opaque plugin fields do not gain Team semantics. Cache invalidation is necessary because parser correction alone cannot recover keys omitted from cached state. The [Team documentation](../../../../packages/experimental/agent-team/README.md) owns the restore behavior. - -`send_message` uses Steer for running, idle, and cold targets. New sends return inbox acceptance. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) records the single-tool scheduling rationale. +Member messages use the subagent `send_message` operation, which steers running, idle, and cold targets and returns inbox acceptance. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. -`wait_agent` blocks on one roster, mailbox, task, or live-status edge registered after the call starts instead of encouraging model polling. It does not replay an earlier edge, so callers re-read authoritative state after wakeup or timeout. Lead-only interruption cancels the current turn with inbox preservation and does not alter mailbox or task ownership. +`wait_agent` belongs to ordinary subagent controls and waits for the exact caller’s next-step inbox, including already-pending input. Task commits do not end waits, deliver messages, or start idle turns. Agents send messages when another member needs to act on a task change. Ancestors may interrupt descendants without changing task ownership. ## Shared checkout boundary -Members start in the Lead’s current directory and observe shared filesystem writes immediately. Later directory changes stay local to each Session. The policy tells members to partition tasks, record advisory write scopes, order dependent work, and let the Lead inspect the final diff and run tests. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. +Members start in the Lead’s current directory and observe shared filesystem writes immediately. Later directory changes stay local to each Session. The task policy tells members to claim ready tasks, use advisory write scopes and dependencies, and complete tasks after doing the work. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. The Team domain does not infer branches, create worktrees, merge changes, or silently change cwd. A deployment or caller can use [explicit worktree creation](2026-09-13-explicit-worktree-creation.md) separately. This preserves the existing same-world subagent and sandbox contracts. @@ -58,36 +52,36 @@ The Team domain does not infer branches, create worktrees, merge changes, or sil The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data. -The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it. +The task panel uses non-deleted task views with readiness and overlap diagnostics, plus `failure` beside the last valid state. Historical roster fields remain decodable but have no current UI or assignment authority. Ordinary subagent navigation owns member discovery; task owner labels reuse Session and catalog metadata. -Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. +Publication requires `apply` to return a new state per applied event and replace only the touched collection. Mailbox-only events retain the client view and publish nothing. The existing carrier sends each changed complete view to connected browsers; the package README records that cost. ## Alternatives considered -**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. +**Keep a separate Team communication authority.** The later [tree-wide communication decision](../architecture/2026-10-16-tree-wide-agent-communication.md) places routing and authorization in the subagent runtime; a task board cannot grant another route. -**Store mail in each target Session before Lead-log enqueue.** Rejected because target materialization and acceptance can fail after the Team has committed the send. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary. +**Keep a durable Team mailbox.** Target admission and task commit are independent operations. The historical Team mailbox remains readable, while current messaging uses the target inbox. Task commits neither deliver messages nor end waits. **Treat task ownership or write scopes as locks.** Rejected because external writers bypass them, crashed owners remain durable, and path-prefix overlap cannot prove semantic independence. False mutual exclusion is more dangerous than an explicit warning. **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. -**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. +**Enable Teams in the default catalog.** Rejected because unsolicited coordination and Team tool schemas would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team without adding Team packages to shipped dependency graphs. -**Use an in-memory board and mailbox.** Roster and task state must survive settlement, HMR, and interruption. Historical mailbox records stay readable; message ownership follows the target inbox. +**Use an in-memory board and mailbox.** Task state must survive settlement, HMR, and interruption. Historical mailbox records stay readable; message ownership follows the target inbox. -**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every roster, task, and receipt. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. +**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every task and wait result. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. ## Testing -Package tests cover Team authority, provisioning durability, task transitions, direct inbox acceptance, sender attribution, lifecycle cancellation, and historical mailbox readability. The keyless Team profile snapshot pins the policy, tools, and workflow; the CLI composition test checks persisted Team and child logs. +Package tests cover task transitions, tree-based assignment, historical roster and mailbox decoding, and task commits that leave inbox waits pending. Ordinary subagent tests cover inbox waiting; recorded Team compositions cover identity, task policy, and tools. -Model-facing member identities and availability follow the [tool projection reference](../../../../packages/experimental/tool-agent-team/README.md); service residency and durable identity remain distinct. +Identity and inbox waiting follow [subagent controls](../../../../packages/subagent/tool-subagent-control/README.md); task policy follows the [Team tool reference](../../../../packages/team/tool-agent-team/README.md). ## Consequences -The Lead Session retains whole task/member snapshots and historical mailbox records. This favors inspectable recovery over compact deltas; deleted and delivered history remains append-only until broader Session retention applies. +The Lead Session retains whole task snapshots and historical member and mailbox records. This favors inspectable recovery over compact deltas; deleted and delivered history remains append-only until broader Session retention applies. -An active roster member can be non-resident, so `inactive` is not failure and sends can incur cold-resume latency. Admission failures reject new sends. Historical pending mail is never delivered. Failed provisioning permanently consumes its name and member slot. +A teammate can be non-resident, so `inactive` is not failure and sends can incur cold-resume latency. Historical pending mail is never delivered. Coordination reduces likely checkout conflicts but cannot eliminate writes outside filesystem compare-and-set tools. The final diff and tests remain the Lead's integration boundary. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index f38c6f68c4..3fbb92daf3 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -10,47 +10,41 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 -Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 +Team 协作需要显式开启;[Session 选择](../architecture/2026-10-20-session-team-mode.zh.md)负责选择粒度与默认组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 ## Decision -每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。 +每个普通根及其后代组成以根 `SessionId` 标识的 Team;持久任务状态从首个任务事件开始。[subagent 队友](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)负责单一 agent 身份。[仅等待收件箱](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)使任务板变更独立于收件箱等待。普通 Session fork 成为一棵新树的根,并排除继承的 Team 权限。 -实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。共享策略声明 Team 可用,并说明协调责任。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。 +实现拆分为 `@deepseek-ai/dsh-agent-team` 与 `@deepseek-ai/dsh-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。基础 bundle 挂载两个插件;Team 不注册与 subagent control 同名的工具。共享策略声明 Team 可用,并说明协调责任。两个包属于 [subagent 家族](../../../../packages/subagent/README.zh.md);根 Session 保存的选择控制任务工具与策略。[Session 模式升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)说明 profile 迁移。 Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。 ## Profile delegation -[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 +[基础 bundle](../../../../packages/bundle/base/README.zh.md) 插入 Team 行,并保留 base 的委派与控制工具;teammate 按[subagent](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)所述使用这些工具启动。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 ## Team identity -共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。[必需 runtime-context 身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留与 fork 处理。 +成员共享统一系统策略和工具模式;执行时强制角色权限。Team 工具解析调用方所属 Team,并使用 Session id 作为任务负责人。[必需运行时身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留和分叉处理。 ## Provisioning and recovery -创建操作先在 Lead Session 中追加并 flush `team/member` provisioning 快照,再通过选定 fresh 或 fork provider 启动预留的 continuable child。初始 inbox 获准前的失败会追加 failed 快照;成功会先 flush child 中已接受的 inbox 条目,再追加 active。恢复会在初始消息仍处于 pending 或已进入用户消息历史时识别它。名字由第一条 provisioning 记录永久保留,包括失败后也不能复用。dispose 会关闭准入,中止并等待已获准的创建与发送操作,再停止 roster 记录的所有 live child;failed child 在 Activation 退出前仍由 cleanup 拥有,cleanup 拒绝会让 dispose 失败。 - -Root 恢复时会把未终结 provisioning 记录与独立持久 child Session 对账。直接 parent 与 continuable descriptor 匹配,并且已经记录初始用户消息,才能证明准入成功并转为 active;缺失、损坏、provider/lineage 不匹配或缺少已准入消息都会转为 failed。creator 会在同一 Lead 日志 serializer 内重读终态;如果 recovery 在创建成功时先标记 failed,creator 会 drain child 并报告 provisioning conflict,而不是遗留孤儿。这样既无需重建从未保存在 Team 日志中的初始 prompt,也能约束插件 reload 竞争。 - -fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前缀,并保留为自己的持久 seed。当前 delegation turn 保持排除,与既有 fork provider 契约一致。 +队友创建和冷恢复属于子代理运行时([子代理队友](../architecture/2026-10-15-agent-teams-as-subagents.zh.md))。历史 `team/member` 记录保留阶段和描述并继续可读;Team 不写入新成员记录。 ## Mailbox and task transactions -历史 Lead-log mailbox 仍是可读格式:queued 与 delivered 记录保留稳定身份与发送者,Team payload 校验仍适用于它们。Team 不再投递或确认这些记录;新发送遵循[直接 inbox 决策](../simplification/2026-09-26-team-direct-inbox.zh.md)。 +历史 Lead-log mailbox 仍可从原始 Session 日志读取。[仅投影任务的决策](../simplification/2026-10-29-team-task-only-projection.zh.md)取代邮箱投影、检查点保留和 Team 载荷校验。历史排队消息绝不再投递或确认。 -事件投影和 checkpoint 准入期间,未知 mailbox 内容保持为已解码 JSON。校验只检查其 type,不重建字段,因为通用对象解析可能省略有效的自有 `__proto__` 键。本地声明的内容变体接受结构校验;不透明的插件字段不获得 Team 语义。缓存失效是必要的,因为只修正解析器无法恢复缓存状态中省略的键。[Team 文档](../../../../packages/experimental/agent-team/README.zh.md)拥有恢复行为的说明。 - -`send_message` 对 running、idle 和 cold target 使用 Steer。新发送返回 inbox 接收结果。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)记录单一工具调度的理由。 +member 消息使用 subagent 的 `send_message` 操作,它对 running、idle 和 cold target 使用 Steer,并返回 inbox 接收结果。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 -`wait_agent` 等待调用注册后发生的下一条 roster、mailbox、task 或实时 status 边,避免模型轮询。它不会回放更早的边,因此调用方需要在唤醒或超时后重新读取权威状态。仅限 Lead 的 interrupt 使用 inbox preservation 取消当前 turn,不改变 mailbox 或 task owner。 +`wait_agent` 属于普通 subagent 控制工具,等待当前调用者的下一步收件箱,包括已有待处理输入。任务提交不结束等待、不投递消息,也不启动空闲轮次。需要另一个成员处理任务变更时,Agent 应发送消息。祖先可中断后代,不改变任务归属。 ## Shared checkout boundary -所有 member 创建时使用 Lead 的当前目录,并立即观察共享文件系统写入。后续目录变更仅影响各自 Session。策略要求 member 切分任务、记录提示性 write scope、为有序工作添加依赖,并由 Lead 检查最终 diff 和运行测试。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 +所有 member 创建时使用 Lead 的当前目录,并立即观察共享文件系统写入。后续目录变更仅影响各自 Session。任务策略要求 member 认领就绪任务、使用提示性 write scope 和依赖,并在完成工作后完成任务。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 Team 领域不会推断 branch、创建 worktree、merge 变更或静默改变 cwd。deployment 或调用方可以另外使用[显式工作树创建](2026-09-13-explicit-worktree-creation.zh.md)。这样保留既有 same-world subagent 与 sandbox 契约。 @@ -58,36 +52,36 @@ Team 领域不会推断 branch、创建 worktree、merge 变更或静默改变 c Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。 -传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 +任务面板使用包含就绪和重叠诊断的未删除任务视图,以及最后有效状态旁的 `failure`。历史名册字段仍可解码,但不拥有当前 UI 或分配权限。普通 subagent 导航负责成员发现;任务所有者标签复用 Session 和目录元数据。 -发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 +发布要求 `apply` 为每个应用的事件返回新状态,且只替换触及的集合。仅邮箱事件保留客户端视图,不发布更新。现有载体向已连接浏览器发送变更后的完整视图;包 README 记录该成本。 ## Alternatives considered -**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 +**保留独立 Team 通信权限。** 后续的[整树通信决策](../architecture/2026-10-16-tree-wide-agent-communication.zh.md)将路由和授权置于 subagent 运行时;任务板不能授予另一条路由。 -**在 Lead 日志入队前把 mail 存入每个 target Session。** 拒绝,因为 Team 提交发送之后,target materialization 与准入仍可能失败。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。 +**保留持久化 Team 邮箱。** 目标接纳和任务提交是独立操作。历史 Team 邮箱保持可读,当前通信使用目标收件箱。任务提交既不投递消息,也不结束等待。 **把 task ownership 或 write scope 当作锁。** 拒绝,因为外部 writer 会绕过它们,崩溃 owner 会持久保留,而路径前缀重叠不能证明语义独立。虚假的互斥保证比明确 warning 更危险。 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 -**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team 并禁用旧 control,同时不向随附依赖图添加 Team 包。 +**在默认工具目录中启用 Team。** 拒绝,因为主动协调与 Team 工具 schema 会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team,同时不向随附依赖图添加 Team 包。 -**使用内存 board 与 mailbox。** roster 与任务状态必须经受 settlement、HMR 与中断。历史 mailbox 记录保持可读;消息归属目标 inbox。 +**使用内存 board 与 mailbox。** 任务状态必须经受 settlement、HMR 与中断。历史 mailbox 记录保持可读;消息归属目标 inbox。 -**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 roster、task 与回执上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 +**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 task 与 wait 结果上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 ## Testing -包测试覆盖 Team 权限、provisioning 持久性、任务转换、直接 inbox 接收、发送者归属、生命周期取消和历史 mailbox 可读性。无密钥 Team profile 快照固定 policy、工具与工作流;CLI 组合测试检查持久 Team 和 child 日志。 +包测试覆盖任务转换、基于树的分配、历史名册和邮箱解码,以及任务提交后收件箱等待保持未完成。普通 subagent 测试覆盖收件箱等待;录制 Team 组合覆盖身份、任务策略和工具。 -模型可见的成员身份与可用状态遵循[工具投影说明](../../../../packages/experimental/tool-agent-team/README.zh.md);服务驻留状态和持久身份仍保持区分。 +身份和收件箱等待遵循 [subagent 控制工具](../../../../packages/subagent/tool-subagent-control/README.zh.md);任务策略遵循 [Team 工具参考](../../../../packages/team/tool-agent-team/README.zh.md)。 ## Consequences -Lead Session 保留完整 task/member 快照和历史 mailbox 记录。这优先支持可检查的恢复,而不是紧凑增量;deleted 与 delivered 历史在更广泛的 Session 保留策略生效前仍只追加。 +Lead Session 保留完整 task 快照以及历史 member 与 mailbox 记录。这优先支持可检查的恢复,而不是紧凑增量;deleted 与 delivered 历史在更广泛的 Session 保留策略生效前仍只追加。 -active roster 成员可能不驻留,因此 `inactive` 不表示失败,发送可能产生冷恢复延迟。准入失败会拒绝新发送。历史 pending 邮件永远不会被投递。provisioning 失败永久占用其名称和成员槽位。 +teammate 可能不驻留,因此 `inactive` 不表示失败,发送可能产生冷恢复延迟。历史 pending 邮件永远不会被投递。 协调可以降低 checkout 冲突概率,但无法消除文件系统 CAS 工具之外的写入。最终 diff 与测试仍是 Lead 的集成边界。 diff --git a/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.i18n.yaml new file mode 100644 index 0000000000..acba92a4e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-08-31-mcp-manager-plugin.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md +/agent-note-settings-driven-mcp-server-manager: + en: 56c40f7db118247c + zh: 3b3c596983cd48e1 +/agent-note-settings-driven-mcp-server-manager/problem: + en: 7115cbaefaa11d2d + zh: 23aff8797a651a36 +/agent-note-settings-driven-mcp-server-manager/decision: + en: 39cf3ae7e8a5799f + zh: 7a26503da73e238f +/agent-note-settings-driven-mcp-server-manager/alternatives-considered: + en: daad4acd963926cc + zh: 152f3d35a4fe3309 +/agent-note-settings-driven-mcp-server-manager/testing: + en: cbfd30400093ade5 + zh: 018ad32e25c6c7fc +/agent-note-settings-driven-mcp-server-manager/consequences: + en: 2a11ba309122335a + zh: 663670e2bf3c5a37 diff --git a/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md new file mode 100644 index 0000000000..653be051a6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md @@ -0,0 +1,63 @@ +# Agent Note: Settings-driven MCP server manager + +Status: implemented + +English | [中文](2026-08-31-mcp-manager-plugin.zh.md) + +## Problem + +One MCP server was one `dsh-mcp-client` row in `cordis.yml`. That is right for composition-pinned servers and wrong for the product goal of managing servers from a Web surface: adding, editing, testing, enabling, disabling, deleting, and recovering servers at runtime, with connection status visible per server. The client plugin exposed no status beyond log lines, activation inherited an unbounded SDK startup wait, and there was no way to test a configuration without mounting it. A survey of six peer agent products (Claude Code, Codex, OpenCode, Kimi Code, DeepSeek-Reasonix, Pi) converged on the same shape: a config registry, a management facade, and a connection-state machine with a status-to-action mapping, secrets never round-tripping to the surface. + +Installed application plugins also bring their own servers. Each must add its server without editing the user's configuration or depending on another application, and disabling or uninstalling the plugin must withdraw the server's tools. + +## Decision + +`packages/mcp/mcp-manager` is a service plugin (`ctx.mcpManager`) that owns the volatile `servers` configuration and reconciles a fleet of ordinary `dsh-mcp-client` child instances; `packages/mcp/mcp-client` owns connection supervision, status, probing, and per-attempt startup budgets. + +**Configuration is volatile Config, and CRUD needs no new API.** Settings projects the manager entry’s Config under its Loader id; ConfigEditor owns profile persistence, inheritance, and revision checks. Settings edits retain inherited servers, and resetting an inherited entry restores its base value. Direct profile patches replace the complete `servers` map. Loader commits the reference before notifying the manager’s existing reconcile queue. + +**Plugins register servers as effects.** `register(name, entry)` adds one server for the calling plugin's lifetime and returns the effect disposer; the entry takes every configured field except `enabled` and passes the same validation. The plugin manager's existing enable, disable, and uninstall operations therefore decide when a plugin's server exists, and the registration's disposal awaits the instance's unmount so the tools are gone when the plugin's disposal settles. Registered and configured names share one namespace: a registration refuses a configured name, and a settings write refuses a registered name. Registrations stay out of Settings. + +**A flat entry schema, validated cross-field.** The entry is flat with a `transport` discriminant, and write-time validation enforces the per-transport requirements and rejections (missing `command`/`url`, cross-transport fields, malformed templates) at the write — a schema-valid-but-unserviceable entry is never stored, and refusals name the offending field where a union's shape error would not. + +**Children are plugin instances, not library calls.** Each enabled entry mounts `dsh-mcp-client` under the manager's fiber via `ctx.plugin()`. That reuses the tested activation/disposal semantics, makes `serverName` reservations collide correctly with statically composed instances (surfaced as a `failed`/`load` status with the actionable message), and lets manager disposal unwind every instance and its tools through ordinary fiber teardown. + +**Whole-entry identity; `enabled` controls mounting.** A changed entry rebuilds the child instance; disabling or deleting it disposes the child. Reconciliation and `restart` serialize on one operation tail. Probes run independently, and configuration changes cancel affected probes before queued cleanup waits for them. The manager retains a failed cleanup record and refuses a replacement until the Host restarts, preventing overlapping server lifetimes. + +**Status belongs to the child Fiber.** The supervisor publishes a cell read through `connectionStatusOf(fiber)`. It survives reloads of that Fiber and remains readable after disposal, which detaches observers. Fiber identity distinguishes instances across registration scopes without identity tokens in status payloads. The manager aggregates cells into `list()` and the `mcp-manager/status` event, absorbing equal statuses and remaining silent after disposal. Removed entries with incomplete cleanup remain listed with `configured: false`. The first reconcile waits for the manager’s own Fiber activation because strict service reads require an active Fiber. + +**Templates keep secrets out of configuration.** Stored env and header values remain readable; a value that must stay hidden belongs in a template. The manager passes raw templates into the child Fiber. Opt-in mcp-client expansion resolves `${credential:REF}` from the current provider, whose local implementation also reads the inherited environment, so there is no separate environment scheme. Missing values leave non-strict clients waiting for credentials; referenced credential notifications can recover them. A changed value replaces the connection only after cleanup succeeds, while a same-value notification preserves the connection and retry budget. Diagnostics, tool-call failures, and `isError` results keep upstream text with every resolved value replaced by `[redacted]`, so the model can act on a server's error without seeing a secret the instance sent. + +**mcp-client budgets and probe.** `startupTimeoutMs` is optional for standalone clients; the manager explicitly supplies a 30-second default. `probeServer(ctx, config, options)` connects, reads server identity, lists tool names, and closes without reserving a name or registering tools. Its own budget and configurable listing caps are independent of runtime startup. Probe success and transport closure are separate results, so successful discovery cannot hide incomplete cleanup. + +**The manager is its own Remote surface.** `McpServerManager` is a Typert Remote service: `watch` streams the complete server list on subscription and after every status or entry change, and `restart` and `probe` call the Host operations. Configuration writes remain on the Settings Remote, and the manager exposes no write method, so a browser cannot define a server command. + +OAuth is deliberately out of scope: the credential-record and authorization seams plus the MCP SDK's `OAuthClientProvider` are the intended home, and a `needs-auth` status state is reserved for that change. + +## Alternatives considered + +**Manage each server as a separate Loader entry.** Rejected: the manager needs one server list with status, restart, and probe. Settings and ConfigEditor already edit its dictionary with inheritance, validation, and revision checks; the runtime child instances need no separate persisted entries. + +**Call `startConnection()` as a library instead of mounting child plugins.** Rejected: it forks the lifecycle from statically composed instances, loses the reservation collision check, and re-implements activation/disposal quiescence the plugin already proves under test. + +**Let plugins write their entry into the manager's Settings namespace.** Rejected: bundle patches replace the whole `servers` map, so two plugins cannot each ship an entry; a plugin must discover the manager's namespace and revision; a settings write returns before reconciliation, so the plugin restarts the entry to wait and mounts it twice; and uninstalling the plugin leaves an enabled entry whose tools stay live. + +**Forward `mcp-manager/status` to clients instead of streaming lists.** Rejected: the Remote event carrier forwards only `emit` events, which do not contain listener failures, and a client would read `list()` after each event to see removals and authorization state. + +**Broadcast per-instance status as a Cordis event from mcp-client.** Rejected: preset-scoped instances can share names, requiring identity tokens in event payloads. A Fiber-owned handle identifies the instance directly; the manager supplies the single aggregated event. + +**Poll status from the management surface.** Rejected: the Remote plane already forwards Host events; peers that shipped polling (Reasonix) or lacked a status event (OpenCode v1) treat it as a defect to fix. + +**A capability seam (Definition/Provider/Consumer) for "MCP management".** Rejected: one implementation and no independently evolving roles; a seam without a second provider is structure without an owner. + +## Testing + +mcp-client unit tests cover status transitions, independent startup and probe budgets, template binding and value redaction, same-Fiber reloads, final readable status, observer containment, cancellation, and separate close and cleanup outcomes. Manager tests use real Cordis, profile-backed Settings, and an in-memory credential provider to cover configuration changes, plugin registrations and their name collisions, credential rotation, probe cancellation, the `watch` stream, failed-cleanup retention, and deletion or disposal during pending work. Real composition tests verify inherited servers, profile patches, persisted settings edits, and write-time validation. Keyless e2e exercises real stdio and HTTP servers, including probes and crash recovery. Both packages meet the per-file 100% coverage gate. + +## Consequences + +- Management operations use Settings for configured add/edit/delete/enable/disable, registrations for plugin-owned servers, `list()`, `watch`, and one event for status, and `probe` for testing. Reconnect and `restart` recover connection failures; incomplete cleanup requires a Host restart. +- Liveness relies on transport closure and request failures; a Streamable HTTP server that stalls without closing is noticed when a request fails. +- A rebuilt server’s tools remain unavailable until the replacement completes its initial sync. Replacement waits for confirmed transport closure and successful local cleanup. +- The manager governs the global plane only; preset-scoped and static instances stay outside its list. +- `startupTimeoutMs` bounds startup attempts, while notification-driven re-sync requests still ride the SDK's request default — recorded as an mcp-client limitation. diff --git a/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.zh.md new file mode 100644 index 0000000000..875e6d3bd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.zh.md @@ -0,0 +1,63 @@ +# Agent Note: Settings-driven MCP server manager + +Status: implemented + +[English](2026-08-31-mcp-manager-plugin.md) | 中文 + +## 问题 + +一台 MCP 服务器就是 `cordis.yml` 里的一条 `dsh-mcp-client` 行。这对组合钉死的服务器是对的,但与产品目标相悖:要从 Web 界面在运行时添加、修改、测试、启用、停用、删除并恢复服务器,且每台服务器的连接状态可见。客户端插件除日志外没有任何状态出口,激活继承了无界的 SDK 启动等待,也没有任何"不挂载就测试一份配置"的途径。对六个同类 agent 产品(Claude Code、Codex、OpenCode、Kimi Code、DeepSeek-Reasonix、Pi)的调研收敛到同一形态:配置注册表、管理门面、带"状态到动作"映射的连接状态机,密钥绝不回读到界面。 + +已安装的应用插件也会带来自己的服务器。每个插件都必须在不编辑用户配置、不依赖其他应用的前提下加入服务器,停用或卸载插件时还必须撤回该服务器的工具。 + +## 决策 + +`packages/mcp/mcp-manager` 是一个服务插件(`ctx.mcpManager`),拥有 volatile `servers` 配置并对一支普通 `dsh-mcp-client` 子实例编队做对账;`packages/mcp/mcp-client` 拥有连接监督、状态、探测与逐次启动预算。 + +**配置由 volatile Config 提供,CRUD 不需要新 API。** Settings 按管理器条目的 Loader id 展示其 Config;ConfigEditor 拥有 profile 持久化、继承和 revision 检查。Settings 编辑保留继承的服务器,重置继承条目会恢复其基层值。直接编写 profile patch 会替换完整 `servers` 映射。Loader 先提交引用,再通知管理器现有的协调队列。 + +**插件以副作用注册服务器。** `register(name, entry)` 为调用方插件的生命周期加入一台服务器,并返回该副作用的释放函数;条目接受除 `enabled` 外的全部配置字段,并通过相同校验。因此插件管理器已有的启用、停用和卸载操作决定插件的服务器何时存在;注册的释放会等待实例卸载,插件的释放完成时工具已撤回。注册名与配置名共用一个命名空间:注册拒绝已配置的名字,设置写入拒绝已注册的名字。注册不进入 Settings。 + +**扁平条目 schema,跨字段校验。** 条目是带 `transport` 判别字段的扁平结构,写入期校验在写入处强制逐传输要求与拒绝(缺 `command`/`url`、跨传输字段、模板写错)——schema 合法但不可执行的条目永远不会被存下,且拒绝会点名出错字段,union 的形状错误做不到。 + +**子实例是插件实例,不是库调用。** 每个启用条目经 `ctx.plugin()` 在管理器 fiber 下挂载 `dsh-mcp-client`。这复用了经过测试的激活/销毁语义,让 `serverName` 预留与静态组合实例正确冲突(以带可操作消息的 `failed`/`load` 状态浮出),并让管理器销毁经普通 fiber 收卷解除每个实例及其工具。 + +**整条目决定实例;`enabled` 控制挂载。** 条目变化会重建子实例,停用或删除条目会销毁子实例。对账与 `restart` 在同一操作队列中串行执行。探测独立运行,配置变更先取消受影响的探测,再由队列中的清理操作等待它们结束。管理器保留清理失败记录,并在 Host 重启前拒绝替换,防止服务器生命周期重叠。 + +**状态属于子 Fiber。** 监督器发布经 `connectionStatusOf(fiber)` 读取的状态单元。同一 Fiber 重载时保留该单元,最终销毁时移除观察者,但单元仍可读取。Fiber 身份直接区分不同注册作用域中的实例,无需在状态载荷中加入身份令牌。管理器聚合为 `list()` 和 `mcp-manager/status` 事件,吸收相等状态并在销毁后停止发出事件。配置已删除但清理未完成的条目仍以 `configured: false` 列出。首次对账等待管理器自身 Fiber 激活,因为严格服务读取要求 Fiber 已激活。 + +**模板让密钥不进入配置。** 存储的 env 和 header 值仍可读,必须隐藏的值应使用模板。管理器把原始模板传入子 Fiber。mcp-client 显式启用模板展开后,从当前 provider 解析 `${credential:REF}`;其本地实现也读取继承的环境变量,因此没有单独的环境变量模板。缺失值使非严格客户端等待凭据,相关凭据通知可恢复连接。值变化时,连接只有在清理成功后才会替换;同值通知保留当前连接和重试预算。诊断、工具调用失败与 `isError` 结果保留上游文本,其中每个已解析的值都替换为 `[redacted]`,模型因此能依据服务器的错误行动,又看不到实例发送过的密钥。 + +**mcp-client 预算与探测。** 独立客户端的 `startupTimeoutMs` 可省略,管理器明确提供 30 秒默认值。`probeServer(ctx, config, options)` 连接、读取服务器身份、列出工具名并关闭,不预留名称,也不注册工具。探测有独立于运行期启动的预算和可配置的列表上限。探测成功与传输关闭分别报告,因此发现成功不会掩盖清理未完成。 + +**管理器自身即 Remote 面。** `McpServerManager` 是 Typert Remote 服务:`watch` 在订阅时以及每次状态或条目变化后推送完整服务器列表,`restart` 与 `probe` 调用宿主操作。配置写入仍走 Settings Remote,管理器不提供写入方法,因此浏览器无法定义服务器命令。 + +OAuth 刻意不在本次范围内:凭据记录与 authorization seam 加 MCP SDK 的 `OAuthClientProvider` 是预期归宿,`needs-auth` 状态位为那次变更保留。 + +## 曾考虑的替代方案 + +**把每台服务器作为独立 Loader 条目管理。** 否决:管理器需要包含状态、restart 与 probe 的统一服务器列表。Settings 与 ConfigEditor 已经为其字典提供继承、校验和 revision 检查;运行期子实例不需要独立持久化条目。 + +**把 `startConnection()` 当库调用而不挂子插件。** 否决:这会让生命周期与静态组合实例分叉,丢掉预留冲突检查,还要重造插件已在测试下证明的激活/销毁静默。 + +**让插件把条目写进管理器的 Settings 命名空间。** 否决:bundle patch 会替换整个 `servers` 映射,两个插件无法各自带一个条目;插件需要反查管理器的命名空间与 revision;设置写入在对账前返回,插件只能重启条目来等待,导致重复挂载;卸载插件后还会留下仍启用的条目,其工具继续生效。 + +**把 `mcp-manager/status` 转发给客户端,而不是推送列表。** 否决:Remote 事件载体只转发 `emit` 事件,而 `emit` 不隔离监听器失败;客户端还需要在每次事件后读取 `list()` 才能看到移除与授权状态。 + +**让 mcp-client 以 Cordis 事件广播逐实例状态。** 否决:preset 作用域实例可以重名,迫使事件载荷携带身份令牌。Fiber 所有的句柄直接识别实例,管理器提供唯一的聚合事件。 + +**让管理界面轮询状态。** 否决:Remote 面已转发宿主事件;发货了轮询的同类产品(Reasonix)或缺状态事件的(OpenCode v1)都把它当作待修缺陷。 + +**为「MCP 管理」开 capability seam(Definition/Provider/Consumer)。** 否决:只有一个实现、没有独立演化的角色;没有第二个 provider 的 seam 是无主的结构。 + +## 测试 + +mcp-client 单测覆盖状态迁移、独立的启动与探测预算、模板绑定与值替换、同一 Fiber 重载、销毁后可读状态、观察者异常遏制、取消,以及独立的关闭和清理结果。管理器测试使用真实 Cordis、profile 支持的 Settings 和内存 credential provider,覆盖配置变化、插件注册及其名字冲突、凭据轮换、探测取消、`watch` 流、清理失败保留,以及待完成工作期间的删除或销毁。真实组合测试验证继承服务器、profile patch、持久化设置编辑和写入期校验。无密钥 e2e 验证真实 stdio 和 HTTP 服务器,包括探测与崩溃恢复。两个包均满足逐文件 100% 覆盖率门禁。 + +## 后果 + +- 管理操作通过 Settings 对配置条目增/改/删/启/停,通过注册加入插件拥有的服务器,通过 `list()`、`watch` 和一条事件获取状态,通过 `probe` 测试。重连和 `restart` 恢复连接失败;清理未完成时需要重启 Host。 +- 存活判断依赖传输关闭与请求失败;停滞但不关闭的 Streamable HTTP 服务器要等到请求失败时才会被发现。 +- 被重建服务器的工具在替换连接完成首次同步前不可用。替换会等待传输确认关闭且本地清理成功。 +- 管理器只治理全局面;preset 作用域与静态实例不在其列表内。 +- `startupTimeoutMs` 只为启动尝试设界,通知驱动的重同步请求仍走 SDK 请求默认值——已记录为 mcp-client 的限制。 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml index fc9f88a600..75d71a51fc 100644 --- a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml @@ -19,7 +19,7 @@ zh: 993378f78f5a4120 /agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers: en: 0b9303333bf482ae - zh: 6dfc68ad9e2924c3 + zh: 8a2ffe660bbe8730 /agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility: en: 754705367c350b31 zh: e10b6541458c4a45 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md index 871eb3fe91..c5437202b5 100644 --- a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md @@ -32,7 +32,7 @@ owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服 每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话: -- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。 +- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以触碰授权范围之外的文件,而其嵌套调用仍保留同样的守卫与路径检查;分享对话框会就此向所有者提示。 - **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。 沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml index 368a5a645b..2db7ec7484 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml @@ -18,8 +18,8 @@ en: ca63fb903b447c2d zh: 3014adbdb0a890ee /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/implementation-and-responsibilities/client-and-sidebar: - en: 5cb8326d8a5b0407 - zh: a31173f990276856 + en: 5be003d933357fee + zh: 0aec7cea1b06a7ce /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/watch-requests-and-notifications: en: 025ba83ae78824df zh: 264d74d2bdfe3d27 @@ -42,8 +42,8 @@ en: d688642e5f272d48 zh: 96088fbc0471aeae /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/directory-node-tree-and-lifecycle: - en: 2ea8615be8d5229b - zh: 0c73fa4ebb06445e + en: eb011d181eeb83e3 + zh: 7d2cb793fd1737b5 /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/user-flows: en: eb4005a334103895 zh: e2c207faffa64e57 @@ -57,8 +57,8 @@ en: 4f343f7a6c4d3ba4 zh: a4390843a20f8dde /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/readiness-races-and-refresh-coalescing: - en: caadd187479e9074 - zh: 3deedd8093f60703 + en: baff3282e8925728 + zh: 2052ca0afadd7a1b /agent-note-on-demand-watching-and-automatic-refresh-for-sidebar-previews-and-file-trees/alternatives-considered: en: 8a8c19229647c760 zh: 2de283fc2c815e65 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index 99639db2e5..8d9005858d 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -60,10 +60,11 @@ Notifications invoke existing reload/list operations; a failed reload does not r | Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service | | `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing | | Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh | -| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` | +| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each pane's root node while a navigator draws the pane's tree ([per-pane state](../architecture/2026-10-20-workspace-tree-state-per-pane.md)); bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` | | Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences | -| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController | -| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads | +| `createFilesStore`, `FilesPaneState`, `FilesTreeState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController | +| `FileNavigator` | [FileNavigator.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileNavigator.tsx), [workspace-tree.ts](../../../../packages/client/ui-sidebar-files/src/client/workspace-tree.ts) | Read per-pane state through framework hooks; convert listed levels into entries and directory states; pass expansion, collapse, and drawing lifetimes to the injected Files face, which owns listing, refresh, and watches | +| `FileTree` | [FileTree.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileTree.tsx) | Draw entries, directory states, and expansion from props; report row actions through `onToggle` and `onOpen`. The root-scoped `sidebar.files.tree` Factory reads localized copy and its local `accessory` Component through framework seats, without owning listings or watches | | Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports | ## Watch requests and notifications @@ -176,13 +177,14 @@ Pause and play use existing icons; manual reload retains its circular arrow. No ```text Files.open - → FilesBody → Session.cwd + Tab - → filesFace.start → DirectoryNode.open + → FileNavigator → Session.cwd + pane + → filesFace.hold → DirectoryNode.open → changes(sessionId, path) → Host.watch(depth: 0) → ready → DirectoryNode → list(sessionId, path) - → createFilesStore.levels[path] - → Level / Entry + → createFilesStore.byPane[paneId].tree.levels[path] + → FileNavigator → workspace-tree.ts: workspaceEntries / workspaceDirectories + → renderFactorySlot('sidebar.files.tree', input) → Level / Entry Entry.expand → filesFace.toggle @@ -204,7 +206,7 @@ The directory panel obtains invalidations and listings through injected Remote c ## Directory-node tree and lifecycle -The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class. +The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each pane's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class. | Node responsibility | Behavior | |---|---| @@ -239,14 +241,14 @@ The parent's watch still detects deletion, renaming, or replacement of a collaps | First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists | | Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences | | Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain | -| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime | +| Switch Tab or hide the tree | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Switching between a pane's file tabs keeps expanded nodes and watchers, because the next tab draws the tree in the same commit; hiding the tree or showing a tab without it closes them while the store keeps expansion and displayed levels, which the next drawing lists again in place | | Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers | -| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state | +| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Once no navigator is mounted in a pane and no open tab last drew one there, every directory stream ends, reads are cancelled, and the pane's state is forgotten | | Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame | -Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set. +Files watches follow each pane's drawn tree. Collapse closes the corresponding subtree; the end of the pane's last drawing releases the root and its entire active tree, and the pane's state is forgotten once no open tab last drew the tree there. The store retains expansion preferences and displayed levels; nodes use them for progressive restoration only when reopened, without a second global expansion set. -`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation. +`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one pane. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation. ## User flows @@ -281,8 +283,8 @@ Observation, reads, and presentation need a few explicit ordering rules, not dis - Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes. - Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs. - File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version. -- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction. -- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure. +- Background directory refresh retains the displayed listing and marks it `refreshing`; the display ignores the mark, while locating a file waits on it for the directory to be listed again. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction. +- After collapse, deletion, the end of a drawing, or the pane's state being forgotten, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure. - Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate. - Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read. diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md index 5d11041a5c..ef557b336a 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -60,10 +60,11 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 | | `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 Host 报告的路径加入 CSS/JS 资源,解析结束后释放不再引用的成员 | | 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision;`failed()` 结束渲染器加载但不记录成功版本。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 | -| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` | +| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,在导航栏绘制分栏目录树期间直接持有各分栏的根节点([按分栏保存状态](../architecture/2026-10-20-workspace-tree-state-per-pane.zh.md));绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` | | 包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 | -| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController | -| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 | +| `createFilesStore`、`FilesPaneState`、`FilesTreeState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController | +| `FileNavigator` | [FileNavigator.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileNavigator.tsx)、[workspace-tree.ts](../../../../packages/client/ui-sidebar-files/src/client/workspace-tree.ts) | 通过框架钩子读取各分栏状态;将已列出的层级转换为条目与目录状态;将展开、折叠和绘制生命周期交给注入的 Files face,由它负责列出目录、刷新与监听 | +| `FileTree` | [FileTree.tsx](../../../../packages/client/ui-sidebar-files/src/client/FileTree.tsx) | 根据属性绘制条目、目录状态与展开状态,通过 `onToggle` 和 `onOpen` 报告行操作。root 作用域的 `sidebar.files.tree` Factory 通过框架席位读取本地化文案与局部 `accessory` 组件,不持有目录列表或监听 | | Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 | ## 监听接口与事件内容 @@ -176,13 +177,14 @@ Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开 ```text Files.open - → FilesBody → Session.cwd + Tab - → filesFace.start → DirectoryNode.open + → FileNavigator → Session.cwd + pane + → filesFace.hold → DirectoryNode.open → changes(sessionId, path) → Host.watch(depth: 0) → ready → DirectoryNode → list(sessionId, path) - → createFilesStore.levels[path] - → Level / Entry + → createFilesStore.byPane[paneId].tree.levels[path] + → FileNavigator → workspace-tree.ts: workspaceEntries / workspaceDirectories + → renderFactorySlot('sidebar.files.tree', input) → Level / Entry Entry.expand → filesFace.toggle @@ -204,7 +206,7 @@ Process → create / remove / rename ## 目录节点树与生命周期 -Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。 +Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各分栏的根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。 | 节点职责 | 具体行为 | |---|---| @@ -239,14 +241,14 @@ workspace/ watch | 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 | | 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 | | 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 | -| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 | +| 切走 Tab 或隐藏目录树 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 在分栏的文件 tab 之间切换时保留已展开目录节点和监听,因为下一个 tab 在同一次提交中绘制目录树;隐藏目录树或显示不带目录树的 tab 时关闭它们,存储保留展开状态与已显示的层级,下一次绘制时就地重新列出 | | 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher | -| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 | +| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | 分栏中没有挂载的导航栏、也没有最近一次在那里绘制导航栏的打开 tab 时,终止全部目录流、取消读取并删除该分栏的状态 | | 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 | -Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。 +Files 监听跟随各分栏已绘制的目录树;折叠关闭对应子树,分栏最后一次绘制结束时释放根节点与整棵活动树,没有打开的 tab 最近一次在该分栏绘制目录树时,还会删除其状态。存储保留展开偏好与已显示的层级,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。 -同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。 +同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个分栏内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。 ## 用户可见动线 @@ -281,8 +283,8 @@ Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则 - 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。 - 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。 - 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。 -- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 -- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 +- 目录后台刷新保留当前列表并标记 `refreshing`;显示不受该标记影响,定位文件据此等待目录重新列出;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 +- 目录折叠、删除、绘制结束或分栏状态被删除后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 - 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。 - 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。 diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml index bf652c02d6..db15a4f23d 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml @@ -9,8 +9,8 @@ en: 7926983089aebab2 zh: 366e32db00674a83 /agent-note-archiving-a-session-with-running-work/decision: - en: 23adfbe72d671d6c - zh: 6fec976acfa54720 + en: 703d8b6f837cc894 + zh: aabe24d201ca0d81 /agent-note-archiving-a-session-with-running-work/alternatives-considered: en: 708268e1fe4b4b75 zh: 64a8f6d6f333e090 diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md index 4bc3bab644..fcaba90418 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md @@ -14,8 +14,8 @@ Archive never hides running work. The registry owns the rule and declares one ca - **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is. - **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive. -- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. -- **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. +- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime reports and cancels running descendants through each Activation’s verified ancestry, with live header ancestry for unmanaged Agents (any depth, never a fork). Releasing a creator does not erase its descendants’ membership. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. +- **Archived means no model step.** The API Session Controller checks archived Sessions and available header ancestry in `agent/pre-step`. The Subagent runtime checks each managed Agent’s retained creation ancestors in the same event; intermediate creators may be offline. Both reject the step, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. - **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md index c7a912fa65..c511810b1d 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md @@ -14,8 +14,8 @@ - **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。 - **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。 -- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 -- **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 +- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 通过各 Activation 已验证的祖先关系报告并取消运行中的后代,非托管 Agent 使用在线 header 血缘(任意深度,从不包括 fork)。释放创建者不会抹去后代的归属。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 +- **已归档即不跑模型步。** API Session Controller 在 `agent/pre-step` 中检查已归档 Session 和可用的 header 血缘。Subagent runtime 在同一事件中检查每个托管 Agent 保留的创建祖先;中间创建者可以离线。两者都会拒绝该步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 - **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml index cc02859b0e..b242ae38e1 100644 --- a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml @@ -6,14 +6,14 @@ en: 8be4b1d210d5d8bf zh: e9124b48467818b3 /agent-note-archive-admission-reads-the-stored-tasks/problem: - en: 093de39b40f7cc97 - zh: 50c712bde174bc96 + en: 4e5bdccf5388d0e5 + zh: 29e68a698f1fa746 /agent-note-archive-admission-reads-the-stored-tasks/decision: - en: afbf818442c0794a - zh: 4749e63d9757bbbb + en: fa45f421aab1315c + zh: d8982ea5833d3770 /agent-note-archive-admission-reads-the-stored-tasks/alternatives-considered: - en: 099360f2c97a50ea - zh: eac4746c4602f152 + en: b38b58e4d91b66ac + zh: f9c7253ffa94d76c /agent-note-archive-admission-reads-the-stored-tasks/consequences: - en: cb2883b34c2c73fa - zh: 9bb1489c9711cf99 + en: b034eb6ea31341b9 + zh: f78029ff55addc4e diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md index 2073ae420d..06a429efe4 100644 --- a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md @@ -8,20 +8,20 @@ English | [中文](2026-09-22-archive-admission-reads-stored-tasks.zh.md) Archive admission asks every provider what still runs for a Session before the archive is written, and a stop-archive then asks those providers to end it ([archiving a Session with running work](2026-09-21-archive-stops-running-session-work.md)). The Schedule provider answered from its owned runtime's fold of the live Session log, so only a Session with a live Agent reported the `schedule` family, and the stop deleted through the management `delete` path. -Host tasks are stored in the Schedule domain document and outlive their Session's Agent ([the storage decision](../architecture/2026-09-16-host-schedule-storage.md)), so that fold no longer exists: `ScheduleRuntime.activeRecords()` is gone, and the stored task table is the only place a due reminder can be seen. A cold Session whose stored rows were still armed therefore reported nothing, archived without the confirmation dialog the running case asks for, and nobody was told which reminder the Host would keep. +Host tasks are stored in the Schedule domain and outlive their Session's Agent ([the storage decision](../architecture/2026-09-16-host-schedule-storage.md)), so that fold no longer exists: `ScheduleRuntime.activeRecords()` is gone, and the stored task table is the only place a due reminder can be seen. A cold Session whose stored rows were still armed therefore reported nothing, archived without the confirmation dialog the running case asks for, and nobody was told which reminder the Host would keep. ## Decision -The Schedule provider answers archive admission from the stored Host task rows, and a stop deletes them. `workspace/session-activity` reads `list({ sessionId })` — the stored rows whose binding is that Session and whose status is active — reports them as the `schedule` family, one item per task with its stored id and title, and prepends that family to `next()` so every other provider keeps its entries. `workspace/session-stop` removes those rows directly inside one slot of the same serialized queue the tools use, so a stop is ordered behind a create whose write is still in flight and cannot miss the row that create is about to commit; it attempts every row, because a row left armed would still fire, and it notifies observers for the rows that landed before the first failure reaches the caller. Session liveness is never consulted: an idle or cold Session whose stored rows are armed refuses the archive, and the confirmation dialog names exactly those reminders. Both listeners are registered in one effect named `schedule.archiveAdmission()`, so unloading the plugin withdraws them together. +The Schedule provider answers archive admission from the stored Host task rows, and a stop deletes them. `workspace/session-activity` reads the stored tasks whose status is active and whose `session` or reserved `new_once` target has the matching Session id, reports them as the `schedule` family, one item per task with its stored id and title, and prepends that family to `next()` so every other provider keeps its entries. `workspace/session-stop` removes those rows in one `deleteMany` batch inside one slot of the same serialized queue the tools use, so a stop is ordered behind a create whose write is still in flight and cannot miss the row that create is about to commit. A successful nonempty batch announces `schedule/changed`, re-drives the timer once, and queues deletion notices for other loaded involved Agents. Session liveness is never consulted: an idle or cold Session whose stored rows are armed refuses the archive, and the confirmation dialog names exactly those reminders. Both listeners are registered in one effect named `schedule.archiveAdmission()`, so unloading the plugin withdraws them together. ## Alternatives considered -**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently, and its due row then dispatches into a turn the gate ends as `blocked`, consuming the occurrence without a model step. The admission answer costs one table read either way. +**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently; its due delivery then rejects the archived destination. The admission answer costs one table read either way. -**Delete each row through the public management `delete` path.** Re-entering the serialized queue from inside the slot that already holds it deadlocks, so the stop removes the rows itself: the same durable change, with one notification and one timer re-drive per stop instead of one per row. +**Delete each row through the `delete` action of public `edit`.** Re-entering the serialized queue from inside the slot that already holds it deadlocks, so the stop removes the rows itself: the same durable change, with one `schedule/changed` notification and one timer re-drive per stop instead of one per row. -**Keep a runtime-side mirror of the Session's active tasks.** A mirror duplicates the authoritative document, drifts after a restart, and disappears with the Agent — the exact case admission must still answer for. +**Keep a runtime-side mirror of the Session's active tasks.** A mirror duplicates the authoritative store, drifts after a restart, and disappears with the Agent — the exact case admission must still answer for. ## Consequences -Admission costs one stored-table read, and a Session with no live Agent can refuse the archive: a Session that looks quiet may now ask for confirmation because its stored reminders are armed. A stop deletes rows one at a time — the domain table offers no batch delete — so a failure partway leaves the earlier deletes committed; observers hear about those, every row was attempted, and the first failure still reaches the caller. One residue follows. A crash between the archive write and the stop leaves stored rows armed in an archived Session: unarchiving shows them again, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself — that resolution is ungated, and only `agent/pre-step` refuses the model step — so a one-shot dispatches a follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step. The seam itself is unchanged: the workspace package still knows no Schedule vocabulary, and this provider owns only reminders. +Admission costs one stored-table read, and a Session with no live Agent can refuse the archive: a Session that looks quiet may now ask for confirmation because its stored reminders are armed. A stop uses one domain `deleteMany` batch. A normal write failure preserves every row; an `unknown-state` failure may have removed rows and quarantines the domain until it is reopened. Either failure announces `schedule/changed` so observers reread and then reaches the caller. A crash between the archive write and the stop can leave stored rows armed in an archived Session; delivery rejects with `schedule/target-archived` before enqueue, without writing a receipt or consuming the occurrence. The seam itself is unchanged: the workspace package still knows no Schedule vocabulary, and this provider owns only reminders. diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md index c95fb280c3..c83ff1c896 100644 --- a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md @@ -8,20 +8,20 @@ 归档准入在写入归档前询问每个提供方:该 Session 还有什么在跑;随后「停止并归档」再要求它们结束这些工作([归档仍有工作在跑的 Session](2026-09-21-archive-stops-running-session-work.zh.md))。Schedule 提供方原先回答的是其归属 runtime 对活会话日志的 fold,因此只有带活 Agent 的 Session 才报告 `schedule` 族,停止也走管理 `delete` 路径。 -宿主任务存储在 Schedule 领域文档中,且比其 Session 的 Agent 活得更久([存储决策](../architecture/2026-09-16-host-schedule-storage.zh.md)),因此那个 fold 已不存在:`ScheduleRuntime.activeRecords()` 已经移除,已存储的任务表是到期提醒唯一能被看见的地方。存储行仍被武装的冷 Session 因此什么也不报告,归档时不会弹出运行态才会弹出的确认对话框,也没有人被告知宿主会继续保留哪条提醒。 +宿主任务存储在 Schedule 领域中,且比其 Session 的 Agent 活得更久([存储决策](../architecture/2026-09-16-host-schedule-storage.zh.md)),因此那个 fold 已不存在:`ScheduleRuntime.activeRecords()` 已经移除,已存储的任务表是到期提醒唯一能被看见的地方。存储行仍被武装的冷 Session 因此什么也不报告,归档时不会弹出运行态才会弹出的确认对话框,也没有人被告知宿主会继续保留哪条提醒。 ## 决策 -Schedule 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取 `list({ sessionId })`——绑定到该 Session 且状态为活动的已存储行——把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里直接删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行;它会尝试每一行,因为留下的行仍会触发;对已经落地的行先通知观察者,再把首个失败交给调用方。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。 +Schedule 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取状态为活动且 `session` 或预留的 `new_once` 目标的 `sessionId` 与该 Session 匹配的已存储任务,把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里通过一次 `deleteMany` 批量写入删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行。非空批量写入成功后发出 `schedule/changed`,重算计时器一次,并向其他已加载的相关 Agent 排入删除通知。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。 ## 已考虑的备选 -**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发。两种做法在准入上都只需一次读表。 +**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档;其到期投递随后因目标已归档而被拒绝。两种做法在准入上都只需一次读表。 -**每行都走公开的管理 `delete` 路径删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次通知、一次计时器重算,而不是每行各一次。 +**每行都通过公开 `edit` 的 `delete` 操作删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次 `schedule/changed` 通知、一次计时器重算,而不是每行各一次。 -**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威文档、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。 +**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威存储、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。 ## 后果 -准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止逐行删除——领域表不提供批量删除——因此删到一半失败时,先前删除已提交;观察者会被告知这些,每一行都已被尝试,首个失败仍交给调用方。由此剩下一处残余:在归档写入与停止之间崩溃,会在已归档的 Session 里留下仍被武装的已存储行;取消归档时它们再次出现;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent——这一步没有门禁,只有 `agent/pre-step` 拒绝模型步——一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发在没有模型步的情况下被消耗。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。 +准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止使用一次领域 `deleteMany` 批量写入。正常写入失败保留全部行;`unknown-state` 失败可能已移除行,并隔离领域直到重新打开。两种失败均发出 `schedule/changed`,让观察方重读,再交给调用方。在归档写入与停止之间崩溃,仍可能在已归档的 Session 中留下仍被武装的已存储行;投递在入队前以 `schedule/target-archived` 拒绝,不写入回执,也不消耗该次发生时点。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。 diff --git a/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.i18n.yaml b/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.i18n.yaml new file mode 100644 index 0000000000..4cb149615f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-09-28-apply-patch-file-editing.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.md +/agent-note-apply-patch-file-editing: + en: 2ff887cb8deab0eb + zh: b503b00d8690955c +/agent-note-apply-patch-file-editing/problem: + en: de9b76d89795ccec + zh: 588d45b7d9ca9582 +/agent-note-apply-patch-file-editing/decision: + en: e3e174513948d0dd + zh: 80b13d7e40d38dee +/agent-note-apply-patch-file-editing/alternatives-considered: + en: fb743c9558cf24ed + zh: e74bdfff00dea417 +/agent-note-apply-patch-file-editing/consequences: + en: b58b7324663c3c90 + zh: 649c18a83e33e71d +/agent-note-apply-patch-file-editing/testing: + en: 82ae0a96fbae53bc + zh: 9a8194bdb134ae8c +/agent-note-apply-patch-file-editing/deferred: + en: 9fa35fed544d9a9d + zh: 32fced0939325341 diff --git a/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.md b/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.md new file mode 100644 index 0000000000..1b911eba1c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.md @@ -0,0 +1,47 @@ +# Agent Note: Apply patch file editing + +Status: implemented + +English | [中文](2026-09-28-apply-patch-file-editing.zh.md) + +## Problem + +A model editing several files or separated regions needs multiple literal-edit calls or a shell script. Shell and Python edits do not expose filesystem mutation intents, so a working directory without Git cannot reliably present those edits in the turn Review tab. A patch tool expresses related edits in one call while retaining guarded filesystem operations. #5121 asks for the capability and its effectiveness evaluation; #4556 supplies the broader motivation for efficient file editing. + +## Decision + +`apply_patch` accepts one Codex-style patch for add, update, delete, and move operations, including multiple files and hunks. The pure [dsh-fs-patch library](../../../../packages/fs/fs-patch/README.md) follows Codex `rust-v0.158.0` parsing, sequential matching, whitespace tolerance, and `NormalizeToLf` reconstruction; the matcher receives LF text only, so Codex `PreserveLineEndings` is not implemented. + +The [filesystem service](../../../../packages/fs/fs/README.md) exposes `patchText`, `deleteFile`, and `moveFile`; local, sandboxed, and SSH providers implement them. `fs/delete-intent` requires the same present observation as `fs/edit-intent`, while move destinations use `fs/write-intent`. Version guards and sandbox enforcement remain with the provider. A move publishes its destination before deleting its source, and `sourceDeleted` distinguishes a completed move from a published destination whose source could not be removed. + +The [tool executor](../../../../packages/fs/tool-fs/README.md) resolves paths and simulates expected state during whole-patch preflight, including repeated paths and moves, without writing files; a refused preflight commits nothing. Commit reobtains observation guards for each operation and stops at the first failure or cancellation without rollback. After an I/O or cancellation failure, a `stat` comparison checks whether every affected target retains its prior version; changed state or a failed comparison produces `unknown`, which requires rereading before retry. Reports retain completed, failed, unknown, and skipped operations, including a partially published move. + +Commit reports return `isError: false` so completed diff metadata survives a stopped patch; their operation statuses and stopped flag expose the failure. Syntax and preflight rejection produce tool errors. Successful move summaries use `M `. Applied diff paths use canonical absolute `processPath` for client navigation, while operation records retain display paths and unresolved paths keep their patch spelling. + +[Review capture](../../../../packages/deliverables/workspace-changes/README.md) uses filesystem intent listeners, not argument parsing. `tools/pre-execute` associates the execution with its turn recorder; prepended write, edit, and delete listeners first await the policy decision, then await capture before provider mutation. Moves capture both endpoints. Each turn retains its first baseline and compares actual final bytes, so repeated edits yield one net diff and reverted changes disappear. Existing size, count, time, location, and Session-lifetime limits remain. + +`tool-fs.applyPatch` defaults to `false`; each shipped `tool-fs` row that enables `edit` states its own value, so turning the tool on or off is one line per composition, or a profile patch on the `tool-fs` id where no preset owns the row. The tool consults the `fs/*` intent waterfalls like `write` and `edit`, so a mounted observation policy requires a prior read and rejects stale versions for every patch operation. The shipped values of those rows and of the `fs-observation-policy` row are decided by [Shipped filesystem mutation defaults](2026-10-29-shipped-fs-mutation-defaults.md), which supersedes this note's opt-in baseline. The schema names its argument `input` as the Codex tool does: across 2,226 recorded DS-Bench and DeepSWE rollouts, 14% of `apply_patch` calls arrived with that key and were rejected while the argument was named `patch`. The tool registers its schema, guidance, native/PTC execution, and tool presentation through plugin effects; the agent loop is unchanged. + +## Alternatives considered + +**Keep using shell or Python for patches.** This leaves non-Git changes without filesystem intent capture and does not provide the requested model-facing editing tool. + +**Reuse Codex markers with an independently strict matcher.** This reduces initial implementation work but makes familiar patches fail differently. A pinned Codex parser and matcher make deviations reviewable; Harness policies stay explicit. + +**Make the whole patch transactional.** Cross-file rollback adds recovery state and can overwrite concurrent external edits. Preflight and ordered commits reject known failures early and expose partial results without promising rollback. + +**Expand Review while adding the tool.** Rename presentation, persistent storage, SSH capture, and capture-failure policy are independent product decisions; existing whole-file capture and rendering cover this integration. + +**Register the tool by default wherever `write` and `edit` are both enabled.** This would add a schema and a guidance section to every default model request and change every recorded request expectation before the tool's effect on ordinary sessions is measured; the explicit switch keeps the baseline composition unchanged and lets comparison runs opt in. The shipped defaults were decided afterwards in [Shipped filesystem mutation defaults](2026-10-29-shipped-fs-mutation-defaults.md). + +## Consequences + +One call can express related edits. A composition that enables the switch includes another tool schema and its guidance, so its recorded request expectations differ from a composition without it. Preflight cannot prevent a later conflict, cancellation, or storage failure; callers inspect operation statuses before retrying. Permissive upstream matching remains pinned and tested rather than tightened. Provider implementations and external subclasses must support three additional operations, and partial moves require handling both endpoints. + +## Testing + +Parser and matcher fixtures cover the pinned syntax, whitespace, repeated contexts, and EOF behavior. Provider and executor tests cover observation, version and sandbox refusals, repeated paths, partial moves, cancellation, and uncertain outcomes. The real-tool Review matrix runs with no repository and with Git unavailable, checking all operation kinds, first-to-last baselines, rejected edits, partial publication, reverted changes, capture ordering, malformed syntax, and path aliases. Real model Session recordings cover successful updates, read-policy rejection and retry, and partial execution; deterministic cancellation stays in executor tests. + +## Deferred + +SSH Review, closing-prose file references for `apply_patch`, and Codex `PreserveLineEndings`. diff --git a/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.zh.md b/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.zh.md new file mode 100644 index 0000000000..6948abe0ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-28-apply-patch-file-editing.zh.md @@ -0,0 +1,47 @@ +# Agent Note: apply_patch 文件编辑 + +Status: implemented + +[English](2026-09-28-apply-patch-file-editing.md) | 中文 + +## 问题 + +模型编辑多个文件或分离区域时,需要多次字面量编辑调用或 shell 脚本。shell 和 Python 编辑不发出文件系统修改 intent,因此没有 Git 的工作目录无法在轮次 Review tab 中可靠呈现这些修改。补丁工具用一次调用表达相关编辑,同时保留受保护的文件系统操作。#5121 要求实现能力并评测效果;#4556 提供高效文件编辑的更广泛动机。 + +## 决策 + +`apply_patch` 接收一份 Codex 风格补丁,支持新增、更新、删除和移动,以及多个文件和 hunk。[dsh-fs-patch 纯库](../../../../packages/fs/fs-patch/README.zh.md)遵循 Codex `rust-v0.158.0` 的解析、顺序匹配、空白容忍和 `NormalizeToLf` 重建。匹配器只接收 LF 文本,因此不实现 Codex `PreserveLineEndings`。 + +[文件系统服务](../../../../packages/fs/fs/README.zh.md)提供 `patchText`、`deleteFile` 和 `moveFile`;本地、沙箱和 SSH 提供方均实现它们。`fs/delete-intent` 与 `fs/edit-intent` 要求相同的存在观察,移动目标使用 `fs/write-intent`。版本防护和沙箱约束仍由提供方负责。移动先发布目标,再删除源;`sourceDeleted` 区分已完成的移动与目标已发布但源未能删除的情况。 + +[工具执行器](../../../../packages/fs/tool-fs/README.zh.md)在整份补丁预检时解析路径并模拟预期状态,包括重复路径和移动,不写入文件;预检拒绝时没有提交。提交时为每条操作重新获取观察防护,在首次失败或取消时停止,不回滚。I/O 或取消失败后,`stat` 比较检查所有受影响目标是否仍保持先前版本;状态变化或比较失败会产生 `unknown`,要求重试前重新读取。报告保留已完成、失败、未知和跳过的操作,包括部分发布的移动。 + +提交报告返回 `isError: false`,让已完成的 diff 元数据在补丁中止后仍被保留;操作状态和 stopped 标记暴露失败。语法和预检拒绝产生工具错误。成功移动的摘要使用 `M `。已应用 diff 的路径采用规范绝对 `processPath`,供客户端导航使用;操作记录保留显示路径,未解析的路径保留补丁原始写法。 + +[Review 采集](../../../../packages/deliverables/workspace-changes/README.zh.md)使用文件系统 intent 监听器,不解析参数。`tools/pre-execute` 将执行关联到轮次记录器;前置的 write、edit 和 delete 监听器先等待策略决策,再等待捕获完成,然后提供方才修改文件。移动捕获两端。每轮保留首次基线,比较实际最终字节,因此重复编辑形成一份净 diff,还原的变化不再显示。现有大小、数量、时间、位置和 Session 生命周期限制保持适用。 + +`tool-fs.applyPatch` 默认为 `false`;每个启用 `edit` 的随发行版交付的 `tool-fs` 行都显式写出自己的值,因此开关该工具是每个组合一行改动,没有 preset 拥有该行时可通过针对 `tool-fs` id 的 profile patch 开启。该工具像 `write` 和 `edit` 一样咨询 `fs/*` intent waterfall,因此已挂载的观察策略对每个补丁操作要求先读并拒绝过期版本。这些行以及 `fs-observation-policy` 行随发行版交付的值由[随发行版交付的文件系统修改默认值](2026-10-29-shipped-fs-mutation-defaults.zh.md)决定,该 Note 取代本 Note 的 opt-in 基线。schema 沿用 Codex 的参数名 `input`:在 2,226 条 DS-Bench 与 DeepSWE 录制轨迹中,14% 的 `apply_patch` 调用带着这个键发出,而参数名为 `patch` 时它们都被拒绝。工具通过插件 effect 注册 schema、指导文本、native/PTC 执行与调用展示;不修改 agent loop(智能体循环)。 + +## 考虑过的替代方案 + +**继续使用 shell 或 Python 打补丁。** 这使非 Git 修改缺少文件系统 intent 采集,也不能提供所需的模型文件编辑工具。 + +**复用 Codex 标记但采用独立的严格匹配器。** 这能减少初期实现工作,但会让熟悉的补丁以不同方式失败。固定 Codex 解析器和匹配器使偏差可审查;Harness 策略继续显式存在。 + +**将整份补丁做成事务。** 跨文件回滚增加恢复状态,还可能覆盖并发外部修改。预检和顺序提交可以提早拒绝已知失败,并公开部分结果,而不承诺回滚。 + +**加入工具时同时扩展 Review。** 重命名展示、持久存储、SSH 采集和捕获失败策略是独立产品决策;既有整文件采集和展示覆盖本次接入。 + +**在同时启用 `write` 与 `edit` 的地方默认注册该工具。** 这会在尚未衡量该工具对普通会话的影响之前,给每个默认模型请求增加一个 schema 和一段指导文本,并改变所有录制请求的预期;显式开关保持基线组合不变,让对比评测自行选择开启。随发行版交付的默认值随后在[随发行版交付的文件系统修改默认值](2026-10-29-shipped-fs-mutation-defaults.zh.md)中决定。 + +## 后果 + +一次调用可以表达相关编辑。开启该开关的组合会包含额外的工具 schema 及其指导文本,因此其录制请求的预期与未开启的组合不同。预检不能阻止之后的冲突、取消或存储失败;调用方在重试前检查操作状态。上游宽松匹配保持固定版本并受到测试约束,不会被收紧。提供方实现和外部子类必须支持三个新增操作,部分移动要求处理两端状态。 + +## 测试 + +解析器和匹配器 fixture 覆盖固定版本的语法、空白、重复上下文与 EOF 行为。提供方和执行器测试覆盖观察、版本和沙箱拒绝、重复路径、部分移动、取消和未知结果。真实工具 Review 矩阵分别在无仓库和 Git 不可用环境运行,检查所有操作类型、首末基线、被拒编辑、部分发布、还原变化、采集顺序、无效语法与路径别名。真实模型 Session 录制覆盖成功更新、读取策略拒绝后重试与部分执行;确定性取消保留在执行器测试中。 + +## 延期工作 + +SSH Review、`apply_patch` 的收尾文案文件引用,以及 Codex `PreserveLineEndings`。 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml index 6f3f674ec6..f779b7be73 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -9,8 +9,8 @@ en: 344bcc7e9825bbc8 zh: c1d0f4b54f5b3786 /agent-note-change-request-panel-and-worktree-sessions/decision: - en: 0b159e4e33409515 - zh: 2bf1eafd644f2d26 + en: 66bde24e7c601eb6 + zh: 5091591f3dc1ac85 /agent-note-change-request-panel-and-worktree-sessions/deferred: en: 32ef8af85f4d2f66 zh: b82b67f94346399e @@ -18,5 +18,5 @@ en: e471e16b6b6450ca zh: 5fbc9242edc8d112 /agent-note-change-request-panel-and-worktree-sessions/consequences: - en: 97976f073b9f65e0 - zh: 95be3f7f375b0876 + en: 0b785a2204963c4f + zh: be4bd8db40b801a1 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md index 7792f8caf9..2b852ad824 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -10,9 +10,11 @@ English | [中文](2026-10-09-forge-panel.zh.md) ## Decision -[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry. +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, the worktree model tool, and an isolated worktree service for the panel as one default-disabled Plugins page entry. -The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. +Review and worktree controls serve one code-change workflow. One Plugins entry lets users enable that workflow without having to distinguish the underlying package split. Packages and the two worktree services remain independent so each owns its before-Session or in-conversation creation flow. + +The Remote inspects the checkout through the Session's subprocess provider under its [resolved permissions and query restrictions](../bug-fix/2026-10-28-forge-git-session-permissions.md), so it reads what the Session's commands see. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default. @@ -24,7 +26,7 @@ The worktree a Session works in can be removed outside it. Each inspection check The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`. -The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other. +The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from `git diff` using the Session's permissions and execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other. Review comments capture the file, side, range, complete hunk, and reviewed head when the user selects code. Agent references enter the existing composer quote mechanism, so the user controls batching and the sent message records exactly what the model receives. GitHub writes belong to the optional forge provider operation: preflight checks the current head, the write addresses that commit, and an ambiguous outcome blocks another write until the user reconciles it. Review drafts and inline records belong to a browser tab occurrence, not to the Session log or a GitHub pending review. Switching Sessions retains that occurrence; closing it discards the local state and rejects late write completions. Published GitHub comments remain external records. @@ -89,4 +91,4 @@ The changes view offers no scope for one commit or for the last turn yet; both w ## Consequences -The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. The bundle mounts two independent worktree services; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md index ea7847b96e..660bd81d38 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -10,9 +10,11 @@ Status: implemented ## Decision -[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。 +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务、工作树模型工具与面板使用的一个隔离 worktree 服务作为插件页中默认禁用的一个条目发布。 -Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 +评审与工作树控件服务于同一个代码修改流程。统一插件入口让用户可以一次启用这些能力,无需根据底层包的拆分判断应该开启哪张卡片。包与两套工作树服务仍保持独立,分别负责会话开始前和对话中创建工作树的流程。 + +Remote 通过 Session 的子进程提供方,按其[解析出的权限与查询限制](../bug-fix/2026-10-28-forge-git-session-permissions.zh.md)检查检出,因此它读到的与 Session 命令看到的相同。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。 @@ -24,7 +26,7 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。 -面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。 +面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自使用 Session 权限与执行环境的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。 评审评论在用户选择代码时固定文件、侧、行范围、完整 hunk 与所评审的 head。Agent 引用进入既有输入框引用机制,由用户决定批量发送,发送后的消息记录模型实际收到的内容。GitHub 写入属于 forge 提供方的可选操作:预检核对当前 head,写入明确指向该提交;结果不明时阻止再次写入,直到用户核对。评审草稿与行内记录归浏览器页签实例所有,不进入 Session 日志,也不创建 GitHub 待提交评审。切换 Session 保留该实例;关闭时丢弃本地状态并拒绝迟到的写入结果。已发布的 GitHub 评论仍保留在外部平台。 @@ -89,4 +91,4 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 ## Consequences -面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。组合包挂载两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml index dea1cb8a9f..98c3a9541a 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml @@ -9,11 +9,11 @@ en: d1aad580a4db9872 zh: 1eb3bac59353291d /agent-note-direct-deepseek-search-and-fetch-apis/decision: - en: e70efbb3aa9d53b6 - zh: 1d25eae2461346df + en: 43298ccae4f7a6d4 + zh: eb7ef46b6f290c15 /agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment: - en: ec653b59d62ed95f - zh: 0d675616059930ba + en: 90e5031d0d2fa9e4 + zh: a9c0229b20555d9e /agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input: en: ad7b3e4fbe528cf2 zh: 8ab8779bfdaaa4fa @@ -21,17 +21,17 @@ en: 7566e400f9ab3a51 zh: 118b2e84d0030480 /agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration: - en: d5a8e36b545f1c04 - zh: d741fd0789401c62 -/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix: - en: 738e32b488bbc1a1 - zh: f33afe570f62599a + en: cb2f0910955b6730 + zh: 7df057048acd7858 +/agent-note-direct-deepseek-search-and-fetch-apis/decision/legacy-messages-search: + en: 0dcc97d8105fab60 + zh: d80d40ae3a0c601d /agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered: - en: 641cbfefc76f2f95 - zh: e3e718473eace85f + en: 907e0b8424943d43 + zh: f45e583e7cb4eb63 /agent-note-direct-deepseek-search-and-fetch-apis/consequences: - en: a55da02f4d3c06c0 - zh: 3c1a624f03f34f28 + en: 7ec21c5b1124e5f8 + zh: af769dbdb4face9f /agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption: - en: 4c84e7bd035b52a7 - zh: 4e5687bda0a47706 + en: 595a35ce727a7c82 + zh: 10b2197663198015 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md index 4369faee79..ee8b912b2e 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md @@ -10,15 +10,15 @@ DeepSeek web retrieval through Messages requires an auxiliary model request for ## Decision -The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields. +The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields, and the [web provider selection decision](../architecture/2026-10-29-web-provider-selection.md) owns how users choose each provider and which providers the shipped composition selects. ### Protocol selection and deployment -The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together. +The APIs register as `deepseek-search-api` and `deepseek-fetch-api`. Messages search remains separately registered as `deepseek-official`. The web service selects each provider through `web.searchProvider` and `web.fetchProvider`, which the Search and Fetch menus on the Web search and fetch page set independently; the shipped composition selects the Search API and local HTTP fetch, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. A saved selection takes effect on the next operation because the web service reads both selections per operation. Each operation resolves one settings snapshot, including its endpoint and credential. Both APIs read the `web-deepseek-api` namespace, and each capability resolves its own endpoint and credential. -The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable. +Direct retrieval appends `/search` or `/fetch` to an API base. A capability without its own YAML endpoint fields uses the origin of the conversation's DeepSeek model route base URL with `/v1/tools`, which for the public deployment is the guide's `https://api.deepseek.com/v1/tools`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that derivation and the credential each operation sends. The verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`; its prefix differs and is never inferred from the hostname, so such a deployment sets the complete base in `searchBaseURL` or `fetchBaseURL`. An unparseable base leaves that capability's provider unavailable. -The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search. +The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A conversation on the `deepseek-account` route sends the account token instead; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users have direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments can select Messages search or set the capability's own base URL to one that serves `/v1/tools`. ### Search results and model input @@ -36,45 +36,41 @@ The HTTP provider retains its own URL validation and resource limits. Selecting ### Default migration -Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages. +The shipped composition selects `deepseek-search-api`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that default and its no-fallback rule. API keys and account tokens both reach the public routes. Deployments behind a gateway without `/v1/tools` routes set `searchBaseURL` to a base that serves them. A stored `web.searchProvider: deepseek-official` selects Messages search, and any other stored selection keeps its provider. -### Supplementary Messages fix +### Legacy Messages search -The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field. +The independent `dsh-web-search-deepseek` provider sends Messages search requests when `deepseek-official` is selected, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. Its `web/deepseek-search-llm-request` Session event type, including the optional `thinking: { type: 'disabled' }` body field that the [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) covers, stays declared and written in `dsh-web-search-deepseek`, so released Sessions that contain the event still open. Messages search sends that field because Harness discards the generated answer and the auxiliary request needs no reasoning. ## Alternatives considered -**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode. - -**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search. +**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request. **Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route. -**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it. +**Require an explicit Search API base.** The Search API is the shipped search default, and an empty default would leave it unavailable until every user enters an address that the DeepSeek model route already determines. Other deployments still set their own base. -**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone. +**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside the Messages provider cannot change fetch routing, so `web_fetch` would stay on HTTP unless the user also edited `web.fetchProvider`; it also ties the APIs' lifecycle to Messages search. Separate registrations let the web service's existing selection choose each provider, without coupling either API to the Messages provider. -**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then. +**Share one transport between Messages search and the new APIs.** A shared web transport would couple different request formats and failure mappings. Credential values instead come from the shared [`llm-deepseek` credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts); Messages search and the Search/Fetch API transports remain independent. -**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other. +**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential; separate packages would duplicate settings that address one deployment. Independent selection does not require separate packages, because each API registers its own provider id. Each operation lives in its own module, so either API can change without the other. -**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages. +**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. This rejection stands for the generic form: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. The [selection decision](../architecture/2026-10-29-web-provider-selection.md) supersedes it for the provider menus, which list every registered provider by its `title`. **Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase. -**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone. - **Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain. ## Consequences -Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. +Direct retrieval requires no auxiliary model tokens, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. -Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The current tool can issue four queries concurrently and fails the batch if one query fails. The provider has no shared rate scheduler, automatic retries, or fallback; other Sessions or processes can exhaust the same account allowance. +Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The tool accepts up to `searchMaxQueries` queries per call, 10 by default, and fails the batch if one query fails. The Search provider sends at most `searchRequestsPerSecond` requests, 5 by default, in any one-second window of one plugin instance, so the second five queries of a full batch start one second after the first. The pacing is not shared across processes, and the provider has no automatic retries or fallback; other processes or clients can exhaust the same account allowance. ## Verification and remaining API adoption -Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. +Provider tests cover request mapping, empty results, malformed responses, independent and derived bases, account-token authentication and its invalidation after HTTP 401, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the result count, and independent Search and Fetch selection. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. The following capabilities remain outside the implemented integration: diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md index 0b736cb445..c7493c8e23 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。 +2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段;[Web 提供方选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责用户如何选择各个提供方,以及内置组合选择哪些提供方。 ### 协议选择与部署 -两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。 +两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索仍独立注册为 `deepseek-official`。Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 选择各个提供方,「网页搜索与获取」页上的 Search 和 Fetch 下拉框分别设置这两项;内置组合选择 Search API 与本地 HTTP 抓取,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。已保存的选择从下一次操作起生效,因为 Web 服务在每次操作时读取两项选择。每次操作解析一份包含端点和凭据的设置快照。两个 API 读取 `web-deepseek-api` namespace,每个能力各自解析端点与凭据。 -两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。 +直接检索在 API 基础地址后追加 `/search` 或 `/fetch`。没有在 YAML 中单独设置端点字段的能力,使用会话的 DeepSeek 模型路由基础地址的 origin 加 `/v1/tools`,公网部署即指南中的 `https://api.deepseek.com/v1/tools`;该推导及每次操作发送的凭据由[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责。已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`;其前缀不同,程序不会根据主机名推断,此类部署需在 `searchBaseURL` 或 `fetchBaseURL` 中填写完整基础地址。基础地址无法解析时,该能力的提供方不可用。 -指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。 +指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。`deepseek-account` 路由上的会话改为发送账号 token;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也能使用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署可以选择 Messages 搜索,或把该能力的单独基础地址设为提供 `/v1/tools` 的地址。 ### 搜索结果与模型输入 @@ -36,45 +36,41 @@ HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek ### 默认值迁移 -将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。 +内置组合选择 `deepseek-search-api`;[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责这项默认值及其不回退规则。API 密钥和账号 token 都能访问公网路由。位于没有 `/v1/tools` 路由的网关之后的部署,需把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `web.searchProvider: deepseek-official` 会选中 Messages 搜索,其他已保存的选择保持其提供方。 -### 附带的 Messages 修复 +### 旧版 Messages 搜索 -保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。 +选中 `deepseek-official` 时,独立的 `dsh-web-search-deepseek` 提供方发送 Messages 搜索请求,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。它的 `web/deepseek-search-llm-request` 会话事件类型(包括[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)覆盖的可选请求体字段 `thinking: { type: 'disabled' }`)仍由 `dsh-web-search-deepseek` 声明并写入,因此包含该事件的已发布会话仍能打开。Messages 搜索发送该字段,是因为 Harness 丢弃生成的回答,辅助请求无需推理。 ## 考虑过的替代方案 -**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。 - -**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。 +**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求。 **复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。 -**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。 +**要求显式填写 Search API 基础地址。** Search API 是内置的搜索默认值,空默认值会让它在每位用户填写一个 DeepSeek 模型路由已经确定的地址之前都不可用。其他部署仍可设置自己的基础地址。 -**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。 +**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** Messages 提供方内的开关无法改变抓取路由,因此除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍会走 HTTP;它还会把两个 API 的生命周期绑在 Messages 搜索上。独立注册让 Web 服务通过现有的提供方选择机制选择各个提供方,不会把任一 API 与 Messages 提供方绑定。 -**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。 +**让 Messages 搜索与新 API 共用一套传输层。** 共享 Web 传输层会耦合不同的请求格式和失败映射。凭据值改为通过共享的 [`llm-deepseek` 凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)获取;Messages 搜索与 Search/Fetch API 传输层保持独立。 -**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。 +**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API;拆成两个包会让指向同一部署的设置重复出现。分别选择不需要拆包,因为每个 API 注册自己的提供方 id。每种操作位于独立模块,任一 API 都可以单独修改。 -**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。 +**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。这一否决对通用表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。对于提供方下拉框,[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)取代了这一否决,下拉框按 `title` 列出所有已注册的提供方。 **让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。 -**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。 - **新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。 ## 影响 -直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 +直接检索不需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 -Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。当前工具可并发发送四个查询,任一查询失败会使整批失败。提供方没有共享限速调度器、自动重试或回退;其他会话或进程也会消耗同一账号的额度。 +Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。工具每次调用最多接受 `searchMaxQueries` 个查询,默认 10 个;任一查询失败会使整批失败。Search 提供方在单个插件实例的任意一秒窗口内最多发送 `searchRequestsPerSecond` 个请求,默认 5 个,因此满批次的后五个查询比前五个晚一秒开始。该限速不跨进程共享,提供方也没有自动重试或回退;其他进程或客户端也会消耗同一账号的额度。 ## 验证与尚未接入的 API 能力 -提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 +提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与推导地址、账号 token 鉴权及其在 HTTP 401 后被作废、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、结果数,以及分别选择 Search 与 Fetch。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 以下能力尚未包含在已实现的接入中: diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml new file mode 100644 index 0000000000..79b2e5d678 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-26-manual-generation-continuation.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md +/agent-note-manual-generation-continuation: + en: 327aaa1cdc63b1f5 + zh: 1147b4a23e51772c +/agent-note-manual-generation-continuation/problem: + en: f118844946afdbdc + zh: f1fe37d042f88b24 +/agent-note-manual-generation-continuation/decision: + en: 463db7deb7710e07 + zh: cfacb69cb58349a9 +/agent-note-manual-generation-continuation/alternatives-considered: + en: 58bb948d210d42ea + zh: 5dc920d222d71218 +/agent-note-manual-generation-continuation/consequences: + en: e86aee3a7da7649f + zh: d58e8cc94e5008bf +/agent-note-manual-generation-continuation/verification: + en: e31a5bd5826c689f + zh: 02ac700146246664 diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md new file mode 100644 index 0000000000..dd7a0e24f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md @@ -0,0 +1,33 @@ +# Agent Note: Manual generation continuation + +Status: implemented + +English | [中文](2026-10-26-manual-generation-continuation.zh.md) + +## Problem + +Users need another request after a model failure without inserting an artificial instruction. Browser paging can omit the failed turn's start, and streaming must not repeatedly copy history. + +## Decision + +The play action submits `content: []` through the existing Session prompt API. The API assigns `source.kind: 'retry'`; unchanged AgentLoop admission records an empty `user/message` and opens a normally balanced new turn. Chat presents a compact continuation-request receipt while the log retains the empty input. The original failed turn remains closed. + +The Session API owns the fixed-size `conversationContinuation` projection and shares its value with the browser. Admission requires an eligible failure, true idle ownership, and no pending human input. An appended empty retry message admits the new turn, preserving eligibility if its request also fails. + +The six-code manual allowlist stays independent of provider retry policy. Non-waking injected context accompanies the request. The accepted user message retains request-id deduplication after the inbox input is claimed. Reusing ordinary message admission avoids a Loop branch and a separate durable acceptance event. + +## Alternatives considered + +**Localized or hidden continuation text.** Adds model-visible instructions and changes retained history. + +**A separate resume operation.** The existing prompt and inbox already carry identified control input and own wakeup. + +**Keep the failed turn waiting for a click.** Changes failure settlement, idle status, cancellation, and model-selection semantics. + +## Consequences + +Ordinary empty-draft Enter remains inert. The explicit action reuses submission state and error reporting without an echo or generated draft. Pre-step rejection and cleared batches still prevent model calls. + +## Verification + +Unit and assembled scenarios cover repeated failure, request deduplication, injected context, balanced turns, retained tools, one original user message, and zero Client event-window reads. diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md new file mode 100644 index 0000000000..dc23da7759 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 手动继续生成 + +Status: implemented + +[English](2026-10-26-manual-generation-continuation.md) | 中文 + +## 问题 + +模型请求失败后,用户需要再次请求,但不应插入人为构造的指令。浏览器分页可能缺少失败轮次起点,流式输出也不能反复复制历史。 + +## 决策 + +播放按钮通过现有 Session prompt API 提交 `content: []`。API 指定 `source.kind: 'retry'`;AgentLoop 沿用普通准入,记录空 `user/message` 并开启正常闭合的新轮次。Chat 显示轻量的继续请求记录,日志保留空输入。原失败轮次保持闭合。 + +Session API 拥有固定大小的 `conversationContinuation` 投影,向浏览器发布相同的值。准入要求失败符合条件、持有真正的空闲状态且没有待消费的用户输入。追加的空重试消息使新轮次获得准入,因此请求再次失败后仍保留继续资格。 + +六码手动白名单独立于提供方重试策略。非唤醒的注入上下文随请求进入。inbox 输入被领取后,已接受的用户消息仍支持 request-id 去重。复用普通消息准入避免增加 Loop 分支及独立持久接受事件。 + +## 考虑过的替代方案 + +**本地化或隐藏的继续文本。** 增加模型可见指令并改变保留历史。 + +**单独的 resume 操作。** 现有 prompt 和 inbox 已可承载带身份的控制输入并拥有唤醒流程。 + +**保持失败轮次等待点击。** 改变失败结算、空闲状态、取消和模型选择语义。 + +## 后果 + +普通空草稿回车仍不执行。显式继续沿用提交状态与错误报告,不创建消息回显或生成草稿。pre-step 拒绝和被清空的批次仍阻止模型调用。 + +## 验证 + +单元和组装场景覆盖重复失败、请求去重、注入上下文、轮次闭合、工具结果保留、仅一条原始用户消息,以及客户端不读取完整事件窗口。 diff --git a/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.i18n.yaml b/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.i18n.yaml new file mode 100644 index 0000000000..7a918c2500 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-29-shipped-fs-mutation-defaults.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.md +/agent-note-shipped-filesystem-mutation-defaults: + en: ca322529f759f46a + zh: a8881e2284631255 +/agent-note-shipped-filesystem-mutation-defaults/problem: + en: 57dcac3bdbcf1c21 + zh: f5bf64888e6dd973 +/agent-note-shipped-filesystem-mutation-defaults/decision: + en: bcdaca6bff9411b7 + zh: 7adfc3a9b9d8853b +/agent-note-shipped-filesystem-mutation-defaults/alternatives-considered: + en: 1249a7868ff2a069 + zh: 739f191121bf5293 +/agent-note-shipped-filesystem-mutation-defaults/consequences: + en: 3def7f0443b41d34 + zh: 70df709d1fcbe8fa +/agent-note-shipped-filesystem-mutation-defaults/testing: + en: 625a8af588dc0ba8 + zh: 9d489f1bbed14fb3 diff --git a/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.md b/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.md new file mode 100644 index 0000000000..542c4fd7ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.md @@ -0,0 +1,32 @@ +# Agent Note: Shipped filesystem mutation defaults + +Status: implemented + +English | [中文](2026-10-29-shipped-fs-mutation-defaults.zh.md) + +## Problem + +[Apply patch file editing](2026-09-28-apply-patch-file-editing.md) shipped `apply_patch` opt-in and kept `dsh-fs-observation-policy` mounted, so every base-backed profile refused `write`, `edit`, and `apply_patch` on a file the model had not read or that had changed since the read. Benchmark runs of the shipped compositions with the tool enabled and with the policy disabled were compared against the baseline, and the shipped defaults were decided from those results in discussion between the maintainers. + +## Decision + +- The `dsh-base` `tool-fs` row (headless, SDK, and ACP) and the Web `standard` preset state `applyPatch: true`. The Web `ptc` and `cordis` presets state `applyPatch: false`, and the shipped [PTC overlay](../../../../apps/cli/config/examples/ptc.yml) sets the same on the base row, so the SDK and Desktop PTC tool sets stay equal without a parity exception. +- The `dsh-base` `fs-observation-policy` row ships `disabled: true`. Every mutating tool in a base-backed profile runs against the bare provider: `write` overwrites, `edit` replaces the unique literal match, and `apply_patch` applies hunks on context match alone. A deployment restores the guard for all three with `disabled: false` on that row. `sdk-minimal` stacks no `dsh-base` and is unaffected. +- The `write` and `edit` guidance keeps asking for a prior read without naming the policy; the `apply_patch` guidance asks for enough hunk context to match one place instead of a prior read. +- The policy package is classified as an optional product package, since no default composition mounts it. The two recordings that capture a policy refusal, `fs-policy-reject` and `apply-patch-rejected`, re-enable the row through a per-scenario `runtime.cordis.yml`; the fixed-budget compaction scenarios pin `applyPatch: false` in both their live and replay patches because their thresholds were tuned to the recorded tool set. + +## Alternatives considered + +**Enable `apply_patch` only in the Web `standard` preset.** The SDK/Desktop parity check compares the SDK profile's `standard` tool set with the Desktop preset, so a Web-only change needs a permanent parity exception for a difference with no product reason; enabling the base row keeps the two equal. + +**Disable the policy for `apply_patch` only.** A `tool-fs` switch that skipped the intent waterfalls for patches alone was built and evaluated, but it left `write` and `edit` under a guard the shipped compositions no longer wanted and added a second configuration axis beside the policy row; disabling the row decides all three tools in one place and keeps the policy package intact for deployments that want it. + +**Enable `apply_patch` in the PTC presets too.** Under PTC the tool is a `run_code` binding rather than a JSON schema, so the cost is small, but the PTC compositions were evaluated without it; the overlay and the Web preset keep the evaluated tool set. + +## Consequences + +Default model requests in base-backed profiles carry the `apply_patch` schema and guidance, so every recorded request expectation outside the fixed-budget compaction scenarios changed. A model can overwrite or edit a file it never read; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/fs-observation-policy-default-off/guide.md) names the one-line patches that restore the guard or drop the tool. Sandbox confinement, Review capture, and `fs/observed` events are unchanged. + +## Testing + +`tool-fs` unit tests cover the unguarded paths (`policy: false`). The keyless SDK smoke edits an existing file the model never read through the shipped `sdk` profile and checks the bytes on disk. `desktop-tools.expected.e2e.ts` compares the SDK and Desktop `standard` and `ptc` tool sets. The recorded corpus replays with the shipped defaults; `fs-policy-reject` and `apply-patch-rejected` replay with the policy re-enabled. diff --git a/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.zh.md b/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.zh.md new file mode 100644 index 0000000000..068077797e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-29-shipped-fs-mutation-defaults.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 随发行版交付的文件系统修改默认值 + +状态:implemented + +[English](2026-10-29-shipped-fs-mutation-defaults.md) | 中文 + +## 问题 + +[Apply patch 文件编辑](2026-09-28-apply-patch-file-editing.zh.md)以 opt-in 方式交付 `apply_patch`,并保持 `dsh-fs-observation-policy` 挂载,因此所有叠加 base 的 profile 都会拒绝对模型未读过、或读后已变化的文件执行 `write`、`edit` 与 `apply_patch`。维护者对随发行版交付的组合分别在开启该工具和禁用该策略的情况下跑了 benchmark,与基线比较后讨论得出了随发行版交付的默认值。 + +## 决策 + +- `dsh-base` 的 `tool-fs` 行(headless、SDK、ACP)与 Web 的 `standard` preset 写出 `applyPatch: true`。Web 的 `ptc`、`cordis` preset 写出 `applyPatch: false`,随发行版交付的 [PTC overlay](../../../../apps/cli/config/examples/ptc.yml) 在 base 行上也设为 `false`,因此 SDK 与 Desktop 的 PTC 工具集保持一致,无需一致性例外。 +- `dsh-base` 的 `fs-observation-policy` 行以 `disabled: true` 交付。叠加 base 的 profile 中每个修改工具都直接作用于裸提供方:`write` 覆盖,`edit` 替换唯一的字面匹配,`apply_patch` 只凭上下文匹配应用 hunk。部署在该行上写 `disabled: false` 即可为三者恢复防护。`sdk-minimal` 不叠加 `dsh-base`,不受影响。 +- `write` 与 `edit` 的提示语仍要求先读,但不再点名该策略;`apply_patch` 的提示语要求给每个 hunk 足够的上下文以匹配唯一位置,而非先读。 +- 由于没有默认组合挂载策略包,它被归类为可选产品包。录的是策略拒绝的两个场景 `fs-policy-reject` 与 `apply-patch-rejected` 通过各自的 `runtime.cordis.yml` 把该行开回来;固定 token 预算的压缩场景在 live 与 replay patch 中都钉住 `applyPatch: false`,因为它们的阈值是按录制时的工具集调的。 + +## 考虑过的替代方案 + +**只在 Web `standard` preset 开启 `apply_patch`。** SDK/Desktop 一致性检查会把 SDK profile 的 `standard` 工具集与 Desktop preset 比较,仅改 Web 需要为一个没有产品理由的差异永久保留一致性例外;开启 base 行让两边保持一致。 + +**只对 `apply_patch` 关闭策略。** 曾实现并评测过一个让补丁跳过 intent waterfall 的 `tool-fs` 开关,但它让 `write` 与 `edit` 继续受随发行版交付的组合已不需要的防护,并在策略行之外增加了第二个配置维度;禁用该行在一处决定三个工具,并为需要防护的部署完整保留策略包。 + +**在 PTC preset 中也开启 `apply_patch`。** PTC 下该工具是 `run_code` 内的绑定而非 JSON schema,代价很小,但 PTC 组合是在没有它的情况下评测的;overlay 与 Web preset 保持评测时的工具集。 + +## 后果 + +叠加 base 的 profile 的默认模型请求带有 `apply_patch` schema 与提示语,因此固定预算压缩场景之外的所有录制请求预期都变化了。模型可以覆盖或编辑从未读过的文件;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/fs-observation-policy-default-off/guide.zh.md)给出恢复防护或去掉该工具的一行 patch。沙箱围栏、Review 采集与 `fs/observed` 事件不变。 + +## 测试 + +`tool-fs` 单元测试覆盖无防护路径(`policy: false`)。keyless SDK smoke 通过随发行版交付的 `sdk` profile 对模型从未读过的既有文件执行 edit 并检查磁盘字节。`desktop-tools.expected.e2e.ts` 比较 SDK 与 Desktop 的 `standard` 和 `ptc` 工具集。录制 corpus 以随发行版交付的默认值回放;`fs-policy-reject` 与 `apply-patch-rejected` 在策略开回的情况下回放。 diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml new file mode 100644 index 0000000000..f4bb734afe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-02-liveshare-plugin-method-catalog.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md +/agent-note-liveshare-plugin-method-catalog: + en: 1daaf32c7ea8d3cd + zh: 70a94350263d22c9 +/agent-note-liveshare-plugin-method-catalog/problem: + en: 2122ac5fdd75f900 + zh: dc135aaa0d1dddc0 +/agent-note-liveshare-plugin-method-catalog/decision: + en: 6e0b897ba6460894 + zh: f05866ccde01a5af +/agent-note-liveshare-plugin-method-catalog/alternatives-considered: + en: 57896311cada7804 + zh: 01fbd1cbd05353ef +/agent-note-liveshare-plugin-method-catalog/consequences: + en: e2069a5f9801f767 + zh: 0698f0b9e1e49201 diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md new file mode 100644 index 0000000000..699c33bbe5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md @@ -0,0 +1,23 @@ +# Agent Note: LiveShare plugin method catalog + +Status: implemented + +English | [中文](2026-11-02-liveshare-plugin-method-catalog.zh.md) + +## Problem + +LiveShare used to expose plugin endpoints through a declaration API. The API let a plugin name arbitrary endpoints, so a catalog mistake could widen a viewer's reach without a corresponding Gateway check. The replacement needs one source of truth that says which plugin methods a share may expose. + +## Decision + +The catalog is enumerated from the Typert local registry at runtime. One entry is emitted per bundle, using the bundle's localized title when available and the component package title otherwise. Endpoint descriptions come from the registered Remote descriptors. A share stores only method-level policies: the Host intersects each policy with the catalog before the Gateway dispatches a call. + +The catalog excludes the DSH core bundles. Their methods remain governed by the existing Session and Workspace sharing roles, not by plugin permissions. The core bundle names are derived from `PROFILE_TEMPLATES`, so the exclusion follows the shipped profile composition instead of a second hand-maintained list. LiveShare's own bundle is excluded because it is the sharing implementation, not a capability a share can grant itself. + +## Alternatives considered + +Keeping the declaration API would leave two sources of truth: a plugin-declared endpoint and the Gateway's own classification. A hand-maintained allowlist in LiveShare would drift from the shipped profile templates. Deriving the exclusion from the actual profile templates keeps the catalog aligned with composition without a second list. + +## Consequences + +A method that is not in the Typert local registry cannot be selected or called through plugin permissions. A plugin that uses an owner-only exact route is absent from the catalog; the viewer sees an `owner-route-not-shared` notice when it calls that route. Adding a shareable method is therefore a plugin-side registration decision, not a LiveShare catalog edit. diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md new file mode 100644 index 0000000000..4f0f639dc4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md @@ -0,0 +1,23 @@ +# Agent Note:LiveShare 插件方法目录 + +Status: implemented + +[English](2026-11-02-liveshare-plugin-method-catalog.md) | 中文 + +## 问题 + +LiveShare 过去通过声明 API 暴露插件端点。该 API 允许插件声明任意端点,因此目录写错时,viewer 的可达范围会在没有对应 Gateway 校验的情况下被扩大。替代方案需要一个唯一事实来源,明确一条分享可以暴露哪些插件方法。 + +## 决定 + +目录在运行时从 Typert 本地注册表枚举。每个 bundle 生成一行,优先使用 bundle 的本地化标题,没有时使用组件包标题;端点说明来自已注册的 Remote descriptor。分享记录只保存方法级策略,Host 在 Gateway 派发前把策略与目录取交集。 + +目录排除 DSH 核心 bundle。它们的方法仍由原有的 Session 和 Workspace 分享角色控制,而不是插件权限。核心 bundle 名从 `PROFILE_TEMPLATES` 派生,使排除规则跟随实际下发的 profile 组合,而不是再维护一份手写名单。LiveShare 自身的 bundle 也被排除,因为它是分享实现,不是分享可以授予自己的能力。 + +## 备选方案 + +保留声明 API 会留下两个事实来源:插件声明的端点和 Gateway 自己的分类。由 LiveShare 维护手写白名单又会与实际下发的 profile 模板漂移。从真实 profile 模板派生排除规则,让目录跟随组合而不需要第二份名单。 + +## 影响 + +不在 Typert 本地注册表中的方法无法被插件权限选中或调用。使用 owner-only exact route 的插件不会出现在目录中;viewer 调用该路由时会看到 `owner-route-not-shared` 提示。因此新增可分享方法是插件侧的注册决定,而不是修改 LiveShare 目录。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 8c2c549e30..73329cbb21 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -9,8 +9,8 @@ en: 3e229ecf646af866 zh: d358507b1e1d381f /agent-note-wine-and-native-windows-ci/decision: - en: 1125b44a170f70fb - zh: 37838b9da2003651 + en: 9038773113cf3553 + zh: 67875d393093c105 /agent-note-wine-and-native-windows-ci/alternatives-considered: en: 0e9a322cfc38d313 zh: f7bdeb86ba4fea1a diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index cb14645f13..c7c8be3f26 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -30,7 +30,7 @@ Native watchers use `canonicalizeWatchPath()` to realpath the deepest existing a Windows durable JSONL paths keep drive roots in native spelling and apply the extended-length namespace only to descendants and staging paths. The ACP teardown ladder uses real Node children, proves graceful and forced tiers with host-appropriate outcomes, and avoids claiming POSIX signal delivery on Windows. Executable fixtures provide `.cmd` shims and `PATHEXT` where the product accepts a bare command. Repository-cache helpers live inside the selected Git subpath so their declared `file:` dependencies expose command shims identically on Windows. The bundled installer exports pnpm's own workspace-ignore configuration, retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT`; nested Git-package installation therefore cannot rejoin the enclosing workspace or let an inherited Windows pnpm executable outrank the transaction-owned wrapper. -Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../../../../apps/cli/src/process-shutdown.ts) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include serializes debounced writes, retries only transient access or busy failures with bounded backoff, and observes every timer rejection. A terminal persistence failure remains on the queue and is rethrown to the teardown owner, while successful teardown drains the latest write. +Post-boot profile watcher setup proceeds only while the root fiber and Loader are both live. A concurrent setup error is contained only when the same invocation's recorded signal already owns shutdown; unrelated HMR failures remain loud. The [process-shutdown controller](../../../../apps/cli/src/process-shutdown.ts) lets a successful one-shot completion drain Node's remaining handles after root disposal, while teardown failure, deadline, and signal escalation retain forced exit. The vendored Include journals runtime changes and writes them through an atomic rename, retrying only transient access or busy failures with bounded backoff. `flush()` and `stop()` reject while changes remain unsaved, and a later successful flush clears the previous error. Shiki disables lazy TextMate-regex compilation and warms each boot grammar before user content enters the unchanged per-line tokenization budget, so scheduler contention cannot publish a partial highlighted stream. The Codex real-product fixture is pinned to stable 0.153.4 schemas and selects an actually advertised command tool and argument shape, preserving the provider-owned protocol while proving unattended rejection and whole-tree exit on each host. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index b999ac8ef8..12ffd61a2c 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -30,7 +30,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 Windows 的持久 JSONL 路径会保留驱动器根目录的原生写法,并仅对后代路径与暂存路径应用扩展长度命名空间。ACP(Agent Client Protocol)拆卸阶梯使用真实 Node 子进程,以符合宿主语义的结果证明优雅终止与强制终止两个层级,并避免声称 Windows 会交付 POSIX 信号。产品接受裸命令时,可执行 fixture 会提供 `.cmd` 包装脚本与 `PATHEXT`。repository-cache 辅助包位于所选 Git 子路径内,因此它们声明的 `file:` 依赖会在 Windows 上以相同方式暴露命令包装脚本。随附的安装器会导出 pnpm 自有的 workspace-ignore 配置,保留 `PNPM_HOME` 作为 pnpm 数据配置,同时从生命周期 `PATH` 中移除该目录,并在 `PATHEXT` 中优先选择 `.CMD`;因此,嵌套 Git 包安装既不会重新加入外层 workspace,也不会让继承的 Windows pnpm 可执行文件抢在事务持有的 wrapper 之前。 -启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../../../../apps/cli/src/process-shutdown.ts)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 会串行化防抖写入,只对瞬时访问或忙碌故障执行有界退避重试,并确保每个由计时器触发的拒绝都得到观察。持久化最终失败后,该故障会保留在队列中,并重新抛给拆卸责任方;成功拆卸则会排空最新写入。 +启动后,只有根 fiber 与 Loader 均处于活跃状态时,系统才会继续设置 profile watcher。只有当同一次调用所记录的信号已取得关闭流程所有权时,系统才会隔离并发设置错误;无关 HMR 故障仍会响亮失败。[进程关闭控制器](../../../../apps/cli/src/process-shutdown.ts)会在根级 dispose 成功后让单次任务的正常完成流程排空 Node 剩余句柄,同时让拆卸失败、截止时间到期和信号升级继续强制退出。vendored Include 通过 journal 记录运行时变更,并以原子 rename 写入,只对瞬时访问或忙碌故障执行有界退避重试。仍有未保存变更时,`flush()` 和 `stop()` 会拒绝;之后一次成功的 flush 会清除先前的错误。 Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持不变的逐行 tokenization(词元化)预算前预热每种启动语法,从而避免调度器争用发布不完整的高亮流。Codex 真实产品 fixture 固定使用稳定版 0.153.4 schema,并选择实际提供的命令工具与对应参数形态;这样既保留由提供方负责的协议,也能在每种宿主上证明无人值守拒绝和整棵进程树退出。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index 9e538f26c6..970eaf9557 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: dc43ba84345e1405 zh: dbc03572ab7710d2 /agent-note-ship-optional-bundles-with-the-installation/decision: - en: 2f20e356517715a6 - zh: 35c7ff8c4cd8a712 + en: 0f3e0daee65b6510 + zh: 2e9c8000da75fb6c /agent-note-ship-optional-bundles-with-the-installation/alternatives-considered: en: 95b48b61757f1f13 zh: e9fb2d6ea492a4bc diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 74f824fc88..aaa055f7b5 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -14,7 +14,6 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/util/official-bundles/src/bu Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -The [single-bundle reference](../../../../packages/experimental/agent-team-profile/README.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy; the [optional-bundle decision for experimental capabilities](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md) owns composition and display metadata; `OPTIONAL_BUNDLES` owns the current shipped list. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index 129f76c454..6135f08763 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -14,7 +14,6 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -[单组合包参考](../../../../packages/experimental/agent-team-profile/README.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略;[实验能力的可选 bundle 决策](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)定义组合与显示元数据;当前随附名单由 `OPTIONAL_BUNDLES` 管理。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml index f00222dc57..fc8bf04b3d 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml @@ -9,11 +9,11 @@ en: d4ab65a39cf4fd7c zh: 62144be9b4fd7956 /agent-note-keep-loader-mutations-non-transactional/decision: - en: 9b661159de63d421 - zh: 15697f468a90edb9 + en: 83268169e9436127 + zh: ce740df03e9fe24a /agent-note-keep-loader-mutations-non-transactional/alternatives-considered: - en: 72b871064c7ae315 - zh: a09e5168f8124c4c + en: 645db43a48b9cfc1 + zh: d03fabd1e82dadbd /agent-note-keep-loader-mutations-non-transactional/consequences: en: 100f687b9d498308 zh: 1c4bc2d989e43ec7 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md index d37539cb3e..58ba37bd3c 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md @@ -12,19 +12,19 @@ Transactional config reload preserves an old plugin generation after a failed ed Revert the five commits in #932, resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins. -Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree. +Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include keeps the running tree after an invalid file, but plugin failures can leave a partially applied tree. Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch. -Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. +Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Include persistence follows the journal adaptation in the vendor ledger: `flush()` and `stop()` reject while changes remain unsaved. -Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. +One #932-specific vendor change remains: Schemastery conditional exports. Include 1.1.1 writes the `initial` file before reading it, so missing-file initialization no longer needs a local change. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators and update return values use the pre-#932 behavior; patch cloning follows upstream Include. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. ## Alternatives considered **Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback. -**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. +**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. **Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover. diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md index 57ad3bb687..9ecadde422 100644 --- a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md @@ -12,19 +12,19 @@ Status: implemented 撤销 #932 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。 -Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。 +Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 在文件无效时保留正在运行的配置树,但插件失败可能留下部分应用的配置树。 应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。 -Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。 +Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久化遵循 vendor 修改记录中的 journal 适配:仍有未保存变更时,`flush()` 和 `stop()` 会拒绝。 -保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 +保留一项 #932 专属 vendor 改动:Schemastery 条件导出。Include 1.1.1 会先写入 `initial` 文件再读取,因此缺失文件的初始化不再需要本地改动。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器及更新返回值采用 #932 前的行为;patch 克隆遵循上游 Include。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 ## 考虑过的替代方案 **保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。 -**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 +**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 **将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。 diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml deleted file mode 100644 index f830391e86..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-26-team-direct-inbox.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md -/agent-note-team-messages-use-the-target-inbox: - en: ae17b789652784b5 - zh: fa462ace95c8cdaa -/agent-note-team-messages-use-the-target-inbox/problem: - en: 83296699f94c77d3 - zh: e681a426bf0a703a -/agent-note-team-messages-use-the-target-inbox/decision: - en: 8d5fed34f19c7302 - zh: 25417793d3797775 -/agent-note-team-messages-use-the-target-inbox/alternatives-considered: - en: e847556d8e9f1071 - zh: bf1abe2f0e201eac -/agent-note-team-messages-use-the-target-inbox/consequences: - en: 24085acb09438dc8 - zh: 27c92e8de8cf7dbe diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml new file mode 100644 index 0000000000..3c03e11100 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-inbox-only-agent-wait.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md +/agent-note-inbox-only-agent-waits: + en: 8a451f9706af8dea + zh: 9818b5abdee12a9c +/agent-note-inbox-only-agent-waits/problem: + en: 7a6e28745bda3305 + zh: e862166572cb9a9f +/agent-note-inbox-only-agent-waits/decision: + en: 553a1e166dd7e7e9 + zh: c05f16577fa6753c +/agent-note-inbox-only-agent-waits/alternatives-considered: + en: 64a44ad3884a90c3 + zh: 1fffe0c6fd78acbe +/agent-note-inbox-only-agent-waits/consequences: + en: 45b6d456c3ca226c + zh: e9ebacd4b6bcaeca diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md new file mode 100644 index 0000000000..80e46f63e6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md @@ -0,0 +1,31 @@ +# Agent Note: Inbox-only Agent waits + +Status: implemented + +English | [中文](2026-10-29-inbox-only-agent-wait.zh.md) + +## Problem + +A task-board commit changes shared state but does not deliver input to a member. Ending every member's wait on each edit couples ordinary subagent controls to Team coordination and makes callers resume without a message explaining what needs attention. + +## Decision + +`wait_agent` observes only its exact caller's next-step inbox through the existing insertion event. It subscribes before checking pending input, consumes nothing, and removes listeners and timers on input, timeout, cancellation, caller disposal, or tool unload. Queued next-turn input, other Agents' activity, and task-board changes do not end the wait. + +Task commands append and flush their revisions without notifying waits or writing inbox messages. A caller sends an explicit message when a member needs to act on an update. No Agent method, shared waiting service, or task-specific waiting event is introduced. + +This supersedes only the wait-signal decision in [Team task boards](../architecture/2026-10-19-team-task-board-inbox.md). Its tree authority, durable task storage, historical non-authority, and separation from ordinary controls remain applicable. No Session event generation changes. + +## Alternatives considered + +**Notify all current waits after every task commit.** This avoids duplicate inbox state but still makes unrelated task edits interrupt every waiting member. Moving the notification behind a service method retains that behavior and coupling. + +**Persist each task change as a message.** The task journal already preserves revisions; per-member notices add unread state and delivery recovery. Explicit messages communicate which updates require action. + +**Add waiting to Agent or its inbox.** Existing input events and tool-owned cancellation already implement the required wait without extending the core API. + +## Consequences + +Task updates alone do not resume a waiting or idle member. Callers must communicate when work should continue. The tool returns no task contents; members query the board when needed. Task changes before or during a wait have the same effect: none. + +Unit tests cover pending input, caller isolation, concurrent waits, cancellation, disposal, and task commits that leave waits pending. The recorded task-board scenario checks past and concurrent commits against timed-out waits, while completion notification remains ordinary inbox input. diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md new file mode 100644 index 0000000000..6bf2e39562 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 仅等待收件箱的 Agent 等待 + +Status: implemented + +[English](2026-10-29-inbox-only-agent-wait.md) | 中文 + +## 问题 + +任务板提交改变共享状态,但不向成员投递输入。每次编辑都结束所有成员的等待,会使普通 subagent 控制依赖 Team 协调,并让调用者在没有消息说明需要关注什么时恢复执行。 + +## 决策 + +`wait_agent` 仅通过现有插入事件观察当前调用者自己的下一步收件箱。它先订阅再检查待处理输入,不消费输入,并在输入到达、超时、取消、调用者释放或工具卸载时移除监听器和定时器。排队的下一轮输入、其他 Agent 的活动和任务板变化不会结束等待。 + +任务命令追加并刷盘修订,不通知等待者,也不写入收件箱消息。需要成员处理更新时,调用者发送明确消息。不新增 Agent 方法、共享等待服务或任务专用等待事件。 + +这仅取代[Team 任务板](../architecture/2026-10-19-team-task-board-inbox.zh.md)中的等待信号决策。树权限、持久任务存储、历史记录不授予权限及与普通控制工具分离的决策仍然适用。不改变 Session 事件代次。 + +## 考虑过的替代方案 + +**每次任务提交后通知所有当前等待。** 这避免了重复收件箱状态,但仍让无关任务编辑打断每个等待成员。将通知移到服务方法后仍保留该行为和耦合。 + +**将每次任务变化持久化为消息。** 任务日志已经保留修订;逐成员通知增加未读状态和投递恢复。明确消息可以表达哪些更新需要行动。 + +**在 Agent 或收件箱中增加等待。** 现有输入事件和工具拥有的取消机制已能实现所需等待,无须扩展核心 API。 + +## 后果 + +仅更新任务不会恢复等待中或空闲的成员。需要继续工作时,调用者必须通信。工具不返回任务内容;成员按需查询任务板。等待前和等待期间发生的任务变化效果相同:都不影响等待。 + +单元测试覆盖已有输入、调用者隔离、并行等待、取消、释放,以及不结束等待的任务提交。任务板录制场景检查过去和并发提交下的等待超时,完成通知仍作为普通收件箱输入。 diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml new file mode 100644 index 0000000000..cf5fb34bc3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-team-task-only-projection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md +/agent-note-team-projection-retains-only-tasks-and-mode: + en: 030f2abb8b5137fe + zh: e661455f374c8b1f +/agent-note-team-projection-retains-only-tasks-and-mode/problem: + en: 43bd1c0daacd2b86 + zh: a35efb4e84cd9288 +/agent-note-team-projection-retains-only-tasks-and-mode/decision: + en: dd21640eb965f332 + zh: 345bc4df573cbadf +/agent-note-team-projection-retains-only-tasks-and-mode/alternatives-considered: + en: 96a0f421f9f92746 + zh: 314f4557f391f90d +/agent-note-team-projection-retains-only-tasks-and-mode/consequences: + en: 1c1b767b7905869f + zh: 1241351bfc433b70 diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md new file mode 100644 index 0000000000..57a85f4105 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md @@ -0,0 +1,29 @@ +# Agent Note: Team projection retains only tasks and mode + +Status: implemented + +English | [中文](2026-10-29-team-task-only-projection.zh.md) + +## Problem + +The Team projection retained historical member, message, and delivery collections after subagent discovery and messaging replaced their runtime consumers. Its retired lifecycle and mailbox validators could still fail the current task board, while checkpoints retained message bodies with no current reader. + +## Decision + +Project only tasks, saved mode, and their recovery metadata. Ignore `team/member` and `team/message/*` payloads without rebuilding or validating their former state machines. This deliberately gives up historical teammate and mailbox state recovery and the associated validation failures. The user accepted that loss; task recovery remains required. + +Checkpoint version 7 invalidates prior state, including failures caused by retired records. The registry rebuilds from the unchanged Session log. Historical event declarations, format migrations, message sources, and attachment export remain supported; no Session format bump or log conversion is needed. + +This partially supersedes the projection and mailbox-validation decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md). The [subagent identity](../architecture/2026-10-15-agent-teams-as-subagents.md) and [inbox-only wait](2026-10-29-inbox-only-agent-wait.md) decisions remain active, as do task durability and shared-checkout semantics. + +## Alternatives considered + +**Retain decode-only roster and mailbox projections.** No production consumer reads these collections. Keeping them preserves message history in checkpoints and lets obsolete validation stop current task operations. + +**Migrate or delete historical log events.** The task board can ignore these records directly. Rewriting them adds migration work and would discard archival data that existing format readers and exports can still use. + +## Consequences + +Old teammate and mailbox state is unavailable through Team projection. Original log records remain readable, but neither grant authority nor trigger delivery. Reintroducing recovery requires a concrete consumer and an explicit decision about its retained data and failure scope. + +Tests cover ignored legacy payloads, version-6 checkpoint invalidation, current checkpoint suffix replay, JSONL restart with task mutation, stable view references, and a recorded headless profile accepting a task after retired lifecycle and mailbox records. Task and mode validation still fail visibly. diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md new file mode 100644 index 0000000000..221abe771b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Team 投影仅保留任务与模式 + +Status: implemented + +[English](2026-10-29-team-task-only-projection.md) | 中文 + +## Problem + +子代理发现和通信替代运行时消费者后,Team 投影仍保留历史成员、消息与投递集合。已退役的生命周期和邮箱校验器仍可能使当前任务板失败,而检查点保留着没有当前读取方的消息正文。 + +## Decision + +仅投影任务、保存的模式及其恢复元数据。忽略 `team/member` 和 `team/message/*` 载荷,不再重建或校验原有状态机。这明确放弃历史队友与邮箱状态恢复,以及相应的校验失败行为。用户接受这一能力损失;任务恢复仍为必要能力。 + +检查点版本 7 使旧状态失效,包括退役记录导致的失败。注册表从未改写的 Session 日志重建状态。历史事件声明、格式迁移、消息来源和附件导出仍受支持;无需提升 Session 格式版本或转换日志。 + +本决策部分取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的投影与邮箱校验决策。[子代理身份](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)和[仅等待收件箱](2026-10-29-inbox-only-agent-wait.zh.md)决策仍有效,任务持久性与共享检出语义也保持有效。 + +## Alternatives considered + +**保留仅供解码的名册与邮箱投影。** 没有生产消费者读取这些集合。保留它们会在检查点中保存消息历史,并允许过时校验阻断当前任务操作。 + +**迁移或删除历史日志事件。** 任务板可以直接忽略这些记录。改写增加迁移工作,还会丢失现有格式读取器和导出功能仍可使用的归档数据。 + +## Consequences + +Team 投影不再提供旧队友与邮箱状态。原始日志记录仍可读取,但不授予权限,也不触发投递。重新引入恢复需要具体消费者,并明确决定保留的数据和失败影响范围。 + +测试覆盖忽略旧载荷、版本 6 检查点失效、当前检查点后缀重放、JSONL 重启后修改任务、稳定视图引用,以及录制的 headless profile 在退役生命周期与邮箱记录之后接受任务。任务和模式校验仍可见地报错。 diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml new file mode 100644 index 0000000000..e4e2d36818 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-02-deepseek-deferred-tool-failures.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md +/agent-note-expose-deepseek-deferred-only-request-failures: + en: 1c4af8b4e2ec0e46 + zh: fcb806d33973025e +/agent-note-expose-deepseek-deferred-only-request-failures/problem: + en: c974406f21854541 + zh: 701cd64cabdfee79 +/agent-note-expose-deepseek-deferred-only-request-failures/decision: + en: 6878caaaebd91a41 + zh: 2ebd5da04dfb1ac6 +/agent-note-expose-deepseek-deferred-only-request-failures/alternatives-considered: + en: d10efff97379cfbb + zh: 572b124ddbb2d86d +/agent-note-expose-deepseek-deferred-only-request-failures/consequences: + en: d60323c595b076f4 + zh: 1bf1068424777b7e diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md new file mode 100644 index 0000000000..78bc8d4a5c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.md @@ -0,0 +1,25 @@ +# Agent Note: Expose DeepSeek deferred-only request failures + +Status: implemented + +English | [中文](2026-11-02-deepseek-deferred-tool-failures.zh.md) + +## Problem + +A synthetic immediate tool can make an all-deferred DeepSeek request acceptable while hiding an invalid tool-update projection. It also changes the request prefix and advertises a tool with no executor. + +## Decision + +The DeepSeek adapter sends deferred declarations unchanged and propagates provider rejection. This replaces the placeholder accommodation described by the [tool-update decision](../architecture/2026-09-20-dynamic-tool-updates.md), whose shared history projection remains authoritative. + +Ordinary addition-only histories start with native tools or an empty set. Removals and first additions to an empty set rebuild native declarations; later deferred additions retain a native baseline. Explicit deferred-only registrations, direct adapter calls, and custom in-history routes can still produce all-deferred requests. Those configurations receive no adapter workaround. + +## Alternatives considered + +**Keep a synthetic immediate tool.** It conceals unexpected deferred-only requests and cannot preserve a prefix that lacked that tool. Automatically activating a real tool instead changes its declared availability. + +**Reject locally.** Provider validation owns acceptance; another local restriction would duplicate it. Reconsider accommodation only for a supported workflow that requires deferred-only requests and cannot retain a native declaration. + +## Consequences + +Deferred-only compatibility is deliberately given up so request errors remain visible. Serializer and HTTP tests verify exact declarations and propagation of a provider rejection. The all-deferred keyless snapshot verifies request construction only; replay success does not establish provider acceptance. Replacement and empty-set scenarios verify the supported addition-only path. diff --git a/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md new file mode 100644 index 0000000000..f075aff3ad --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-11-02-deepseek-deferred-tool-failures.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 显露 DeepSeek 全延迟请求失败 + +Status: implemented + +[English](2026-11-02-deepseek-deferred-tool-failures.md) | 中文 + +## Problem + +合成的立即加载工具可使 DeepSeek 接受全延迟请求,却掩盖无效的工具更新投影。它还会改变请求前缀,并声明一个没有执行器的工具。 + +## Decision + +DeepSeek 适配器原样发送延迟声明,并传递提供方的拒绝错误。这取代了[工具更新决策](../architecture/2026-09-20-dynamic-tool-updates.zh.md)中描述的占位工具兼容机制;其中的共享历史投影仍是权威依据。 + +普通 addition-only 历史以原生工具或空集开始。移除工具或首次向空集添加工具会重建原生声明;后续延迟添加保留原生基线。显式全延迟注册、直接调用适配器和自定义 in-history 路由仍可产生全延迟请求。适配器不为这些配置提供变通处理。 + +## Alternatives considered + +**保留合成的立即加载工具。** 它会掩盖意外的全延迟请求,也无法保留原本没有该工具的前缀。改为自动激活真实工具则会改变其声明的可用性。 + +**在本地拒绝。** 是否接受请求由提供方验证决定;额外的本地限制会重复该验证。只有受支持工作流确实需要全延迟请求,且无法保留原生声明时,才重新考虑兼容处理。 + +## Consequences + +有意放弃全延迟兼容性,使请求错误保持可见。序列化和 HTTP 测试验证精确声明以及提供方拒绝错误的传递。全延迟无密钥快照仅验证请求构造;回放成功不代表提供方接受。替换和空集场景验证受支持的 addition-only 路径。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 8cbe83c2f7..31c799468d 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml @@ -29,6 +29,9 @@ /agent-note-frontend-large-session-performance-budgets/calibration/history-selector-invalidation: en: 2d2f78f4cf6aad8a zh: aec6af5f7c66de16 +/agent-note-frontend-large-session-performance-budgets/calibration/streaming-assistant-visibility: + en: 5dc3c84b80be0e8e + zh: 4bbd16e1f455ef85 /agent-note-frontend-large-session-performance-budgets/alternatives-considered: en: 4d2a4a64ccd14507 zh: 8a4aaf4d4abea13d diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 16277f4f37..50cc863788 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -102,6 +102,23 @@ These local runs both failed the independent open budget: their open medians wer Post-GC browser heap was 80.70/80.71 MiB. GNU time reported maximum RSS of 535.78/561.80 MiB for each benchmark command and its children; this is not input-phase memory or an aggregate browser-memory measurement. Moving the composer's selection reveal into an animation frame was rejected: profiling showed the style flush moved to another geometry read, without improving typing. The product fix removes the invalidation source instead. +### Streaming Assistant visibility + +Chat retains visibility per Assistant block. Same-kind text and reasoning appends inspect only the incoming delta until the block becomes visible, then reuse that result. This avoids repeated `trim()` calls on accumulated strings without changing whitespace, first-token timing, or visible anchors. Replacements recalculate visibility, retry resets it, and final messages rebuild it. Copying the block array remains proportional to its length; this does not make every Assembler operation constant-time. + +The [conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) measures a single 256 KiB block split into 4,096 or 16,384 deltas, using ordinary text or reasoning whose first half is whitespace. Five fresh compiled Node children per case reuse the existing benchmark registrations, production Assembler and Chat builder. Timing includes every append, one final flush, and reading every output character. Fixture preparation and GC are excluded; retained heap is the post-GC increment with the Assembler, live entries and published state still reachable. + +Local macOS arm64 measurements on Node 26.8.2 give these elapsed medians. The time limits use reference allowances of 12/32 ms and the existing shared scale and headroom; they are not new hosted-runner calibration. + +| Workload | Deltas | Repeated accumulated trim | Cached visibility | Time limit | +|---|---:|---:|---:|---:| +| Ordinary text | 4,096 | 42.25 ms | 7.32 ms | 30 ms | +| Half-leading-whitespace reasoning | 4,096 | 252.75 ms | 7.96 ms | 30 ms | +| Ordinary text | 16,384 | 206.71 ms | 23.06 ms | 80 ms | +| Half-leading-whitespace reasoning | 16,384 | 1114.18 ms | 23.08 ms | 80 ms | + +All four cases reject accumulated trimming and pass with cached visibility. Retained heap remains approximately 2.8/9.9 MiB for the two fragment counts, below the 20 MiB limit derived from a 16 MiB allowance. Maximum worker RSS across these samples falls from 185.70 to 89.36 MiB; it is a whole-process diagnostic, not aggregate application memory. This endpoint measures local assembly with one final publication, excluding network, browser rendering and Trajectory. Separate browser regressions cover settlement, cancellation, retry, reload, reopening and blank reasoning without asserting a browser speedup. + ## Alternatives considered **Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index 159c31f935..1c469a7768 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -102,6 +102,23 @@ Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scrol 强制 GC 后的浏览器 heap 为 80.70/80.71 MiB。GNU time 为两次基准命令及其子进程报告的最大 RSS 为 535.78/561.80 MiB;这不是输入阶段内存,也不是浏览器总内存测量。曾尝试将输入框的选区显示推迟到动画帧,但分析显示样式刷新转移到另一处几何读取,没有改善键入,因此未采用。产品修复直接移除样式失效的来源。 +### 流式 Assistant 可见性 + +Chat 保存每个 Assistant 块的可见状态。同类型文本和推理追加只检查新增片段,直到该块可见,之后复用结果。这避免对累计字符串重复调用 `trim()`,同时保留空白、首 token 时间及可见锚点。块替换重新计算可见性,重试清空状态,最终消息重建状态。块数组复制仍与其长度成正比,因此并非每次 Assembler 操作都是常数时间。 + +[conversation-fold worker](../../../../benchmarks/conversation-fold/conversation-fold.worker.client.ts) 测量单个 256 KiB 块,拆成 4,096 或 16,384 个片段,内容为普通文本或前半段为空白的推理。每个用例使用五个全新的已编译 Node 子进程,复用现有基准注册、生产 Assembler 和 Chat builder。计时包含每次追加、一次最终 flush 及逐字符读取输出。夹具准备和 GC 不计时;保留堆内存为 GC 后的增量,此时 Assembler、实时条目和已发布状态仍可达。 + +macOS arm64、Node 26.8.2 上的本地测量得到以下耗时中位数。时间上限使用 12/32 ms 参考额度及现有共享缩放与余量,不代表新的托管运行器校准。 + +| 工作负载 | 片段数 | 累计文本重复 trim | 缓存可见性 | 时间上限 | +|---|---:|---:|---:|---:| +| 普通文本 | 4,096 | 42.25 ms | 7.32 ms | 30 ms | +| 前半段为空白的推理 | 4,096 | 252.75 ms | 7.96 ms | 30 ms | +| 普通文本 | 16,384 | 206.71 ms | 23.06 ms | 80 ms | +| 前半段为空白的推理 | 16,384 | 1114.18 ms | 23.08 ms | 80 ms | + +四个用例都拒绝累计文本重复 trim,并在缓存可见性后通过。两个片段规模的保留堆内存仍约为 2.8/9.9 MiB,低于 16 MiB 额度经余量计算后的 20 MiB 上限。这些样本中的 worker 最大 RSS 从 185.70 降至 89.36 MiB;它是整个进程的诊断值,不是应用总内存。该终点只测量一次最终发布的本地组装,不包含网络、浏览器渲染和 Trajectory。独立浏览器回归覆盖结算、取消、重试、刷新、重开及空白推理,不据此声称浏览器提速。 + ## 考虑过的替代方案 **用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index 0fa00ec1bf..778be9ae01 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: 37faaffb4662fd54 - zh: df0a4bf016393be9 + en: 3ceb80f455e9a47d + zh: 89d8bd11b42d31b0 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 559a8037caa9e3aa - zh: 4f3719ed09445cc2 + en: b5bfb4cfa231ce0f + zh: 631b9f2978241bc1 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index c3c361047f..7c92d9c72e 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Absolute time budgets use each side's minimum sample mean, with the mean process CPU time from that sample. Doubling checks take the median of the five within-round ratios, keeping the adjacent samples paired through aggregation. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,7 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered -- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. +- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the sample counts, GC inclusion, workloads, and limits. Independent minima can still select rounds at different speeds; the median of paired ratios prevents one faster half-sized sample from deciding the scaling result. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. - **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index 7541efa2de..f5b31146b8 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。绝对耗时预算取两侧样本均值的最小值,并保留对应样本的平均进程 CPU 时间。翻倍检查取五个同轮次耗时比的中位数,使相邻样本在汇总时仍保持配对。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,7 +54,7 @@ Status: implemented ## 考虑过的替代方案 -- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 +- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留样本数、GC 计入规则、负载与上限。独立选取最小值仍可能选中运行速度不同的轮次;配对比值的中位数避免单个更快的小输入样本决定翻倍结果。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 - **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 diff --git a/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.i18n.yaml b/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.i18n.yaml new file mode 100644 index 0000000000..8db24c9d07 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.i18n.yaml @@ -0,0 +1,43 @@ +# Bilingual-pair consistency record for 2026-10-29-event-triggered-automation-tasks.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.md +/agent-note-event-triggered-automation-tasks: + en: fe57a78907eab083 + zh: 4ac96eeb365ac012 +/agent-note-event-triggered-automation-tasks/problem: + en: 491a791a201d0b2c + zh: 5a98f693ac4b5e77 +/agent-note-event-triggered-automation-tasks/proposal: + en: 7906d9f791e0ba02 + zh: 3a083d451344f072 +/agent-note-event-triggered-automation-tasks/proposal/layers: + en: 8bf2fc8ccb162c59 + zh: e993b9ae72f8d9a2 +/agent-note-event-triggered-automation-tasks/proposal/sessions: + en: 7ec83684c8c19e31 + zh: 27df2725836e5f7b +/agent-note-event-triggered-automation-tasks/proposal/configuration: + en: 21be7e27081aff54 + zh: 7973f7f0d064db35 +/agent-note-event-triggered-automation-tasks/proposal/mechanisms: + en: 2403ec84b12279ec + zh: 7e245af54dcf4a1a +/agent-note-event-triggered-automation-tasks/proposal/product-scope: + en: c003c908f407ccb2 + zh: 6113569a44d52202 +/agent-note-event-triggered-automation-tasks/proposal/event-triggers-as-infrastructure: + en: 4c62e16718896e9b + zh: 9c61ea9b6e1f4f22 +/agent-note-event-triggered-automation-tasks/proposal/delivery-order: + en: 79cf498bf3566051 + zh: 4537f1bd3df527b4 +/agent-note-event-triggered-automation-tasks/alternatives-considered: + en: 21df079f7d7c3f4d + zh: 67f4ab221aacf2cf +/agent-note-event-triggered-automation-tasks/acceptance-criteria: + en: 504feb2cc59f4457 + zh: c78cd50f2f336562 +/agent-note-event-triggered-automation-tasks/risks: + en: 35edb8d5b6a1cfa4 + zh: cb8dbc339c6055be diff --git a/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.md b/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.md new file mode 100644 index 0000000000..af3f170fca --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.md @@ -0,0 +1,153 @@ +# Agent Note: Event-triggered automation tasks + +Status: proposed + +English | [中文](2026-10-29-event-triggered-automation-tasks.zh.md) + +## Problem + +Automation tasks run only on time rules today. The Host stores each rule, and every due time queues the task's instruction as a follow-up message in the task's original Session ([Host-owned scheduled messages](../../implemented/architecture/2026-09-16-host-schedule-storage.md)); the model creates and edits tasks through preset-scoped tools ([Preset-scoped Schedule tools](../../implemented/architecture/2026-09-24-preset-scoped-schedule-tools.md)). Work that should start when something else changes, such as a pull request's checks failing, a reviewer requesting changes, or a message arriving, cannot be expressed as a task, so users check those sources by hand across Sessions. + +Reacting to changes brings two problems that time rules do not have. + +- **Cost and noise.** Most observations of a source contain nothing worth a model turn. If every observation reaches the model and silence depends on the model replying with a marker, each poll costs a full turn, and a model that holds a messaging tool reports "nothing new" anyway. +- **Untrusted input.** Event content is written by other people: comments, reviews, messages. A turn started by that content must not edit, push, or send anything before the user has seen it. + +## Proposal + +An automation task becomes an instruction, a source with parameters, and a Session mode. Time is one source among several, so a scheduled task is an automation task whose source is time. Each observation passes three layers, and only the last one calls a model: a source event passes the trigger's filter and becomes a candidate, the candidate is delivered to a Session where the model judges it, and only a notify call produces a notice for the user. This partially supersedes the two notes linked above: tasks stop being bound to their original Session, and task records stop meaning inbox delivery only. + +```mermaid +flowchart TD + Task["Task: instruction, source parameters, Session mode
written by the model, shown read-only"] + Prov["Source providers: plugins, code
time, GitHub; later mail, IM, memory"] + Trig["Event trigger: shared code, state per task
compare with stored state, match parameters, merge"] + Rec[("Run records")] + Src{"Source"} + Exec["Run the instruction in the target Session
with its permissions"] + RO["Read-only turn in the task's own Session
the model judges the candidate"] + Note["Notice: action card, unread mark,
system notification"] + Quiet["No notice"] + Acc["User accepts: the action runs in the Session
that created the task, with its permissions"] + Task --> Trig + Prov -- "source events" --> Trig + Trig -- "each change or failure" --> Rec + Trig -- candidate --> Src + Src -- time --> Exec + Src -- "other sources" --> RO + RO -- notify --> Note + RO -- "no notify call" --> Quiet + Note --> Acc +``` + +| Layer | Decides | Model call | Output | +|---|---|---|---| +| Source provider | What changed in the source | No | Events | +| Event trigger | Whether the change matters to this task | No | A run record; a candidate or nothing | +| Agent | Whether the user needs to act, and what to propose | Only for delivered candidates | A notice through notify, or nothing | + +### Layers + +**Source providers** are code, packaged as plugins; they provide event sources and are unrelated to model, SSH, or credential providers. A provider owns one kind of source: it polls or subscribes, reports changes as events with a subject, a one-line summary, detail, and links, and declares the JSON schema of the parameters a task can set. One provider serves every task of its kind. Time is the first provider, and today's schedule rules become its parameters. Any installed plugin can register a provider, including one written in Creator mode; a task can name only a registered provider. + +**The event trigger** is shared code, registered per source kind and version; each task holds only its parameters and the source state it last observed. For each observation it compares the new state with the stored one, and an observation without a change only updates the last-checked time. A check that turns from passing to failing is a change, a check that stays failing is not, and a pass followed by a new failure is a change again. For a change, the trigger matches the task's conditions, which are parameter fields such as conclusion is failure, author is the user, or commenter is not a bot; merges changes to the same subject inside the provider's merge window, so one push that fails three checks becomes one event; stores the new state with the task; and either delivers a candidate to the Session or drops the change. Each change and each failure writes a run record, listed in the task's Records tab next to today's delivery records: candidate delivered, filtered with its reason, or failed. Consecutive failures with the same cause share one record with a count. Delivery ends the trigger's work: it does not wait for the turn or read a receipt. One queue per task orders its evaluations. + +**The agent** receives the candidate as a message in the target Session, together with the task's instruction in the user's words. What the turn may do depends on the source. + +- **Time**: the turn runs the instruction with the Session's permissions, and its reply is the result, as scheduled tasks behave today. +- **Other sources**: the turn gets the read-only tool set described under Mechanisms. It can read files and the source's state, such as check logs and pull request diffs, but has no tool that edits, pushes, or sends. One new tool, **notify** (`automation_notify`), takes a title, a summary, and a proposed action and produces a notice: an [action card](../../../../packages/experimental/action-cards/README.md) with the proposed action in the Session, the Session's unread mark, and a desktop system notification. The model decides whether to call it. A turn without notify produces no notice; there is no end tool, and nothing checks how the turn ended. When the user accepts the card, the Host sends a follow-up to the Session that handles the task, as described under Sessions; the follow-up names the task and the event and quotes the proposed action as the automation's proposal, not as the user's words, and that turn runs with that Session's permissions. + +### Sessions + +A task delivers to its target Session in one of three modes: a specified Session, a new Session per trigger, or a new Session on the first trigger that later triggers reuse. Time tasks keep today's target, the Session they were created in. Event tasks separate judging from handling. The read-only turn runs in the task's own Session, created on the first trigger and reused afterwards, because it needs the instruction, the event, and the task's earlier decisions rather than the long history of the conversation the task came from. When the model creates a task in a conversation, it writes the facts the judgment needs into the instruction, such as which check is known to be flaky. When the user accepts a card, the proposed action goes to the Session the task was created in, where the code and its history are. The automation page's New action already creates tasks in a new Session, which then handles them; a task enabled from an official template, which a later step adds, continues in its own Session. The user can change the mode on the task. + +No task runs while the Host is stopped. On restart, each task compares the current source state with its stored state once and does not replay intermediate changes. Time keeps today's rule: a recurring task runs its latest missed occurrence once. + +### Configuration + +Configuration has two layers. Provider code decides what can be observed and how; task parameters decide what one task watches. The model writes the parameters from the user's words through the task tools, and the provider's schema validates them. A task carries no scripts or other code, so observing something new means changing or installing a provider plugin. The automation page shows source parameters read-only, and they change through conversation; the time editor stays. + +### Mechanisms + +- **Event-turn tool set.** Sandbox read-only mode limits only the filesystem, so it cannot stop a push or an outbound request. An event turn is offered a fixed tool set instead of the Session's: file reads and searches, the source provider's read operations, and `automation_notify`. Shell, file writes, web fetch, and MCP tools are absent. The tool set is recorded in the Session log, and a user message queued during the turn starts a normal turn after it ends. +- **Notify card.** The automation package registers an action-card kind, and `automation_notify` creates its card through the action-card service; the tool exists only in event turns, so no skill load precedes it. Accepting runs the kind's handler, which queues the follow-up through the Host, so the handling Session need not be open. A newer card replaces an unanswered older one in the same Session, as the service does today; the older card is in that Session's history, so the newer turn sees it and carries over what is still open. A refused card returns an error to the turn. +- **Unread mark.** The client derives the unread mark from turn completion. A task's own Session is marked as an automation Session, and for such a Session the client shows the mark only for notices, so a turn without notify leaves no mark. +- **Logging.** The delivered candidate, the turn's tool set, and the notify call are Session events, so replay rebuilds the turn; the TypeScript and Python SDK expected outputs and a recorded-session snapshot cover them. +- **Storage.** Source, parameters, Session mode, trigger state, and run records extend the Host task domain in a new record version with an adjacent migration. A version-1 task reads as a time task, is not rewritten until it changes, and gets no invented records. Run records have their own count and age bounds, separate from delivery records. +- **Provider configuration.** Polling intervals and request budgets are validated Config fields of each provider in cordis.yml. + +### Product scope + +- No new entry point and no page redesign: scheduled and event-triggered tasks share the automation page. +- The task list adds a source-type filter next to the status filter. +- Task details add the source with its parameters, read-only, and the Session mode, editable. +- A task's Records tab also lists filtered and failed evaluations. +- Notifications reuse the Session unread mark and action cards; the desktop app adds a system notification. + +### Event triggers as infrastructure + +The trigger layer is shared infrastructure for DSH, not a feature of the automation page. A feature that watches something and hands changes to an agent becomes a task on this layer instead of building its own polling, state, records, and notifications. + +- **DSH's own state as sources**: a Session turn ending in an error, a background job settling, and, once memory lands, a memory consolidation pass finishing or memory entries changing. +- **Features on those sources**: plugin recommendations, where a workflow the user repeats by hand shows up after consolidation and the agent proposes a plugin on an action card, and task recommendations, where a review of the day's Sessions proposes follow-up work. Each is a template task with a DSH source, an instruction, and the same notify step. The plugin recommendations package, which proposes cards from conversation turns today, can move onto such a source once consolidation lands. +- **What every consumer keeps**: change detection and condition matching in code, run records, Session modes, read-only event turns, and notify as the only way a task reaches the user. + +### Delivery order + +1. Task–Session decoupling with the three Session modes. +2. The trigger layer with time as its first provider and the task-domain migration; existing scheduled tasks behave as before. +3. The GitHub provider for pull request checks, reviews, and comments; the event-turn tool set and `automation_notify`; action cards leave experimental status or ship with the automation bundle. +4. Mail and IM providers and official templates, then DSH's own sources. + +## Alternatives considered + +**Judge every observation in a resident Session.** The model reads each poll and replies with a silence marker when nothing changed. It lost because each observation costs a full turn over the Session's context, polls spaced beyond the prefix cache lifetime read that context uncached, and silence depends on the model following an instruction while it holds a messaging tool. Here code drops unchanged and unmatched observations, and only a notify call reaches the user. + +**Judge in the Session that created the task.** That conversation already holds the code and its history, so the instruction would need no extra facts. It lost because most candidates produce no notice, and each of them would add a turn over the conversation's whole context, read uncached once the prefix cache has expired, and queue behind or interrupt the user's own work there. Only accepted work goes to that Session. + +**Let the agent choose the Session for each event.** The choice could differ from one event to the next, so the user could not predict where a notification appears, and deciding whether the history is needed requires reading it first. + +**Condition scripts per task.** A task would carry a script that fetches and decides. It lost because the script runs with the user's credentials without the review a plugin installation gets, and each task would carry its own fetch logic instead of sharing a provider. + +**Notify from code when code can decide.** A failed check could notify without a model. It lost because the notification would carry only the raw status. With one path, the agent reads the logs first and the notification states the cause; change-only delivery keeps these turns rare. + +**One model call outside any Session.** A single call per event costs less and leaves no Session record. It lost because the call cannot read files or logs before deciding, and acting on its result needs a Session anyway. + +**Event turns with the Session's permissions.** Acting directly would save one click. It lost because event content comes from other people, and a read-only turn keeps that text from driving edits, pushes, or messages before the user accepts. + +**A read-only sandbox for event turns.** It lost because sandbox read-only mode limits only the filesystem, and a turn in it can still push or send requests. + +**An end tool and a check on how the turn ended.** A second tool would record why a turn produced no notice, and the trigger would wait for that outcome. It lost because nothing acts on that reason: delivery ends the trigger's work, and a turn without notify is already silent. + +**Parameter forms on the automation page.** Each provider would need its own form, validation, and display. Parameters written by the model and shown read-only cover creation and change through conversation, and the provider schema keeps a generated form possible later. + +**A separate notification entry.** An inbox page or tab would separate the notification from the Session that holds its context. The unread mark and the system notification lead to that Session, and the automation page keeps the records. + +## Acceptance criteria + +- Existing scheduled tasks behave the same after time becomes a provider: the same due times, the same catch-up rule, and the same delivery into the Session. +- A version-1 task loads as a time task without being rewritten and without invented records. +- A GitHub task on a pull request's checks delivers one candidate when a check turns from passing to failing, none while it stays failing, and another after a pass followed by a new failure. +- One push that fails three checks produces one candidate. +- An observation without a change writes no record and starts no turn; a change that fails the conditions writes a filtered record and starts no turn; consecutive failures with the same cause write one record with a count. +- A run record ends at delivery: it records the candidate, not what the turn did. +- An event turn is offered no shell, file-write, web-fetch, or MCP tool, and its tool set appears in the Session log; after the user accepts its card, the next turn runs with the Session's permissions. +- A notify call produces the card, the unread mark, and the desktop system notification together. +- An event turn without a notify call leaves no unread mark and sends no system notification. +- An event task's read-only turns run in the task's own Session, and accepting its card sends the proposed action to the Session the task was created in. +- Each Session mode delivers as defined: a specified Session receives every trigger, a new Session opens per trigger, or one Session opens on the first trigger and is reused. +- Replaying the Session log rebuilds an event turn's candidate, tool set, and notify call. +- After a restart, a task compares the current source state with its stored state once and does not replay intermediate changes. +- The task list filters by source type, task details show source parameters read-only, and the Session mode can be changed. + +## Risks + +- The read-only turn sees the instruction, the event, and the task's earlier decisions, not the conversation the task came from. Facts the judgment needs from that conversation must be written into the instruction when the task is created; a user who switches the task to its original Session gets the history back at the cost of reading the long context on each delivery. +- A notification appears in the task's own Session, not in the conversation that created the task; accepting its card moves the work to that conversation. +- The agent may notify too often or stay silent when it should not. Accepted and dismissed cards show over-notification, but a missed notice leaves no trace in the run records, which end at delivery. +- Source parameters cannot be edited by hand; every change goes through the model. +- A fix on an event waits for the user to accept the card instead of running unattended. +- A source without a provider plugin cannot be watched. +- Tasks run only while the Host runs: nothing runs while the app is closed or the machine sleeps. +- Event tasks depend on action cards, which are experimental and mounted only by the Personalized recommendations bundle today. +- The read-only tool set limits judgment to what files and the provider's read operations show; for a source whose provider offers no read operations, the agent judges from the event alone. diff --git a/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.zh.md b/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.zh.md new file mode 100644 index 0000000000..c23e4684c8 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-10-29-event-triggered-automation-tasks.zh.md @@ -0,0 +1,153 @@ +# Agent Note: 事件触发的自动化任务 + +Status: proposed + +[English](2026-10-29-event-triggered-automation-tasks.md) | 中文 + +## 问题 + +目前自动化任务只能按时间规则运行。Host 保存每条规则,每到一个到期时间,就把任务的指令作为一条后续消息排进任务的原会话([Host 拥有的定时消息](../../implemented/architecture/2026-09-16-host-schedule-storage.zh.md));模型通过按 preset 提供的工具创建和修改任务([按 preset 提供的 Schedule 工具](../../implemented/architecture/2026-09-24-preset-scoped-schedule-tools.zh.md))。有些工作应当在别处发生变化时开始,例如 PR(Pull Request)的检查失败、评审人要求修改、收到一条消息,这些都无法建成任务,用户只能在多个会话之间手动查看这些来源。 + +响应变化会带来两个时间规则没有的问题。 + +- **成本和噪音。** 对来源的大多数观察里,没有值得花一个模型轮次处理的内容。如果每次观察都交给模型,并依靠模型回复一个标记来保持安静,每次轮询都要花一个完整轮次;手里有发消息工具的模型仍然会报告“没有新情况”。 +- **不可信的输入。** 事件内容由其他人写成:评论、评审、消息。由这类内容启动的轮次,在用户看到之前不能修改、推送或发送任何东西。 + +## 提案 + +自动化任务改为由指令、带参数的来源和会话模式组成。时间是多种来源中的一种,因此定时任务就是来源为时间的自动化任务。每次观察依次经过三层,只有最后一层调用模型:来源事件经过触发器过滤成为候选事件(candidate),候选事件投递到会话,由模型在会话中判断,只有调用通知工具才会为用户产生一条通知(notice)。本提案部分取代上面链接的两篇笔记:任务不再绑定原会话,任务记录也不再只表示收件箱投递。 + +```mermaid +flowchart TD + Task["Task: instruction, source parameters, Session mode
written by the model, shown read-only"] + Prov["Source providers: plugins, code
time, GitHub; later mail, IM, memory"] + Trig["Event trigger: shared code, state per task
compare with stored state, match parameters, merge"] + Rec[("Run records")] + Src{"Source"} + Exec["Run the instruction in the target Session
with its permissions"] + RO["Read-only turn in the task's own Session
the model judges the candidate"] + Note["Notice: action card, unread mark,
system notification"] + Quiet["No notice"] + Acc["User accepts: the action runs in the Session
that created the task, with its permissions"] + Task --> Trig + Prov -- "source events" --> Trig + Trig -- "each change or failure" --> Rec + Trig -- candidate --> Src + Src -- time --> Exec + Src -- "other sources" --> RO + RO -- notify --> Note + RO -- "no notify call" --> Quiet + Note --> Acc +``` + +| 层 | 判断什么 | 是否调用模型 | 输出 | +|---|---|---|---| +| 来源提供方(source provider) | 来源里发生了什么变化 | 否 | 事件 | +| 事件触发器(event trigger) | 这个变化与本任务是否相关 | 否 | 一条运行记录;一个候选事件,或者没有 | +| agent(智能体) | 用户是否需要处理,以及建议怎么处理 | 只对已投递的候选事件 | 通过通知工具产生通知,或者没有 | + +### 分层 + +**来源提供方**是代码,以插件形式提供;它们提供事件来源,与模型、SSH 或凭据的提供方无关。一个来源提供方负责一类来源:它轮询或订阅来源,把变化报告为事件(包含对象、一行摘要、详细内容和链接),并声明任务可设置参数的 JSON schema。同类的所有任务共用同一个来源提供方。时间是第一个来源提供方,现有的定时规则成为它的参数。任何已安装的插件都可以注册来源提供方,包括在创造模式中写出的插件;任务只能指定已注册的来源提供方。 + +**事件触发器**是共享的代码,按来源类型和版本注册;每个任务只保存自己的参数和上次观察到的来源状态。每次观察时,它把新状态与保存的状态比较;没有变化的观察只更新最近检查时间。检查从通过变为失败算一次变化,持续失败不算,通过之后再次失败又算一次变化。对一次变化,触发器先匹配任务的条件(参数中的字段,例如结论为失败、作者是用户本人、评论者不是 bot),再在来源提供方的合并窗口内合并同一对象的多次变化(一次推送导致三项检查失败,只算一个事件),然后把新状态和任务一起保存,向会话投递一个候选事件,或者丢弃这次变化。每次变化和每次失败都写一条运行记录,列在任务的运行记录页签中,与现有的投递记录并列:已投递候选事件、已过滤(附原因)或失败。同一原因的连续失败共用一条记录并计数。投递之后触发器的工作就结束了,不等待该轮次,也不读取回执。每个任务用一个队列排定自己的判定顺序。 + +**agent** 在目标会话中以一条消息收到候选事件,消息附带以用户原话写成的任务指令。轮次能做什么取决于来源。 + +- **时间**:轮次按会话的权限执行指令,回复就是结果,与现在的定时任务一致。 +- **其他来源**:轮次使用“机制”一节所述的只读工具集。它可以读文件和来源的状态,例如检查日志和 PR diff,但没有修改、推送或发送的工具。新增一个工具 **通知**(`automation_notify`),接收标题、摘要和建议的操作,产生一条通知:会话中出现带有建议操作的[操作卡片](../../../../packages/experimental/action-cards/README.zh.md)、会话显示未读标记、桌面端发送系统通知。是否调用由模型决定。没有调用通知的轮次不产生通知;没有结束工具,也不检查轮次如何结束。用户接受卡片后,Host 向处理该任务的会话(见“会话”一节)发送一条后续消息:消息写明任务和事件,并把建议的操作作为自动化提出的建议引用,而不是作为用户的话;该轮次按该会话的权限执行。 + +### 会话 + +任务按三种模式之一投递到目标会话:指定的会话、每次触发新建会话、首次触发时新建会话并在之后的触发中复用。定时任务沿用现在的目标,即创建它的会话。事件任务把判断和处理分开。只读轮次在任务自己的会话中运行,首次触发时新建,之后复用,因为它需要的是指令、本次事件和该任务之前的判断,而不是任务来源会话的长历史。模型在会话中创建任务时,把判断需要的事实写进指令,例如哪项检查已知不稳定。用户接受卡片后,建议的操作发往创建任务的会话,代码和相关历史都在那里。自动化页的“新建”本来就在一个新会话中创建任务,之后由该会话处理;从官方模板启用的任务(后续步骤加入)在自己的会话中继续执行。用户可以在任务上修改模式。 + +Host 停止期间不运行任何任务。重启后,每个任务把当前来源状态与保存的状态比较一次,不重放中间的变化。时间沿用现有规则:周期任务只补跑最近一次错过的时间点。 + +### 配置 + +配置分两层。来源提供方的代码决定能观察什么、如何观察;任务参数决定一个任务观察什么。模型根据用户原话,通过任务工具写入参数,由来源提供方的 schema 校验。任务不携带脚本或其他代码,因此要观察新的东西,就修改或安装来源提供方插件。自动化页以只读方式显示来源参数,参数通过对话修改;时间编辑器保留。 + +### 机制 + +- **事件轮次的工具集。** 沙箱的只读模式只限制文件系统,挡不住推送或外发请求。因此事件轮次使用固定的工具集,而不是会话的工具集:读文件和搜索、来源提供方的读取操作,以及 `automation_notify`。没有 shell、写文件、网页抓取和 MCP 工具。工具集记录在会话日志中;轮次进行中排队的用户消息,在该轮次结束后作为普通轮次执行。 +- **通知卡片。** 自动化包注册一种操作卡片类型,`automation_notify` 通过操作卡片服务创建这类卡片;该工具只在事件轮次中提供,因此调用前不需要加载 Skill。接受卡片时执行该类型的处理器,由处理器通过 Host 排入后续消息,所以处理任务的会话不必处于打开状态。同一会话中,新卡片会替换尚未处理的旧卡片,与操作卡片服务现在的行为一致;旧卡片在该会话的历史中,新的轮次能看到它,并把仍未处理的内容带到新卡片里。被拒绝的卡片会把错误返回给该轮次。 +- **未读标记。** 客户端根据轮次完成推导未读标记。任务自己的会话标记为自动化会话,对这类会话,客户端只为通知显示未读标记,因此没有调用通知的轮次不留下标记。 +- **日志。** 已投递的候选事件、轮次的工具集和通知调用都是会话事件,回放可以重建该轮次;TypeScript 和 Python SDK 的期望输出以及一个录制会话快照覆盖这些内容。 +- **存储。** 来源、参数、会话模式、触发器状态和运行记录以新的记录版本扩展 Host 任务域,并提供相邻迁移。版本 1 的任务按时间任务读取,在发生修改前不重写,也不补造记录。运行记录有自己的数量和时长上限,与投递记录分开。 +- **来源提供方配置。** 轮询间隔和请求额度是每个来源提供方在 cordis.yml 中经过校验的 Config 字段。 + +### 产品范围 + +- 不新增入口,不重做页面:定时任务和事件触发的任务共用自动化页。 +- 任务列表在状态筛选旁增加来源类型筛选。 +- 任务详情增加来源及其参数(只读)和会话模式(可修改)。 +- 任务运行记录同时列出已过滤和失败的判定。 +- 通知复用会话的未读标记和操作卡片;桌面端新增系统通知。 + +### 事件触发作为基础设施 + +触发层是 DSH 共用的基础设施,而不是自动化页的一项功能。需要观察某样东西并把变化交给 agent 的功能,都作为这一层上的任务实现,不再各自实现轮询、状态、记录和通知。 + +- **DSH 自身状态作为来源**:会话轮次以错误结束、后台作业结束;memory 落地后,还包括一次 memory 整理完成,或 memory 条目发生变化。 +- **基于这些来源的功能**:插件推荐,即 memory 整理后发现用户反复手动执行的流程,由 agent 在操作卡片上推荐插件;任务推荐,即回顾当天的会话,提出后续要做的事。每一项都是一个模板任务,由一个 DSH 来源、一条指令和同样的通知步骤组成。插件推荐包现在从对话轮次中提出卡片,memory 整理落地后可以改为基于这类来源。 +- **每个使用方都保留的规则**:变化检测和条件匹配由代码完成、运行记录、会话模式、只读的事件轮次,以及只有通知能让任务触达用户。 + +### 交付顺序 + +1. 任务与会话解耦,支持三种会话模式。 +2. 上线触发层和任务域迁移,以时间作为第一个来源提供方;现有定时任务行为不变。 +3. GitHub 来源提供方,覆盖 PR 的检查、评审和评论;事件轮次工具集和 `automation_notify`;操作卡片结束实验状态,或随自动化 bundle 一起提供。 +4. 邮件和 IM 来源提供方以及官方模板,然后是 DSH 自身的来源。 + +## 考虑过的替代方案 + +**在常驻会话中判断每次观察。** 模型读取每次轮询的结果,没有变化时回复一个静默标记。没有采用,原因是:每次观察都要在会话的完整上下文上花一个轮次;轮询间隔超过前缀缓存的有效期时,每次都要在没有缓存的情况下读取整个上下文;而且模型手里有发消息工具时,能否保持安静取决于它是否遵守指令。本方案由代码丢弃没有变化和不匹配的观察,只有调用通知才会触达用户。 + +**在创建任务的会话中判断。** 该会话已经有代码和相关历史,指令里不需要额外写事实。没有采用,原因是大多数候选事件不产生通知,每一次都会在该会话的完整上下文上增加一个轮次,前缀缓存过期后要在没有缓存的情况下读取整个上下文,用户正在那里工作时还要排队或打断。只有被接受的工作才会进入该会话。 + +**由 agent 为每个事件选择会话。** 每次的选择可能不同,用户无法预料通知出现在哪里;而且要判断是否需要历史,得先读取历史。 + +**每个任务自带条件脚本。** 任务携带一个脚本,由脚本获取数据并作出判断。没有采用,原因是脚本以用户的凭据运行,却没有经过安装插件时的审查;而且每个任务都要自带获取逻辑,无法共用来源提供方。 + +**代码能判断时由代码直接通知。** 检查失败可以不经模型直接通知。没有采用,原因是这样的通知只包含原始状态。只保留一条路径时,agent 会先读日志,通知中写明失败原因;只在变化时投递,这类轮次很少。 + +**在会话之外调用一次模型。** 每个事件调用一次模型,成本更低,也不留下会话记录。没有采用,原因是这次调用在判断前无法读取文件或日志,而且要根据结果采取行动仍然需要会话。 + +**事件轮次沿用会话的权限。** 直接行动可以省去一次点击。没有采用,原因是事件内容来自其他人,只读轮次能防止这些文字在用户接受之前驱动修改、推送或发消息。 + +**事件轮次使用只读沙箱。** 没有采用,原因是沙箱的只读模式只限制文件系统,处于该模式的轮次仍然可以推送或发出请求。 + +**结束工具和对轮次结束方式的检查。** 第二个工具可以记录某一轮为什么没有产生通知,触发器则要等待这个结果。没有采用,原因是没有任何环节会使用这个原因:投递之后触发器的工作就结束了,而没有调用通知的轮次本来就是静默的。 + +**在自动化页提供参数表单。** 每个来源提供方都需要各自的表单、校验和展示。由模型写入参数并以只读方式展示,已经覆盖通过对话创建和修改;来源提供方的 schema 让以后生成表单仍然可行。 + +**单独的通知入口。** 收件箱页面或标签页会把通知与持有其上下文的会话分开。未读标记和系统通知都指向该会话,自动化页保留记录。 + +## 验收标准 + +- 时间改为来源提供方后,现有定时任务行为不变:到期时间相同、补跑规则相同、投递到会话的方式相同。 +- 版本 1 的任务按时间任务加载,不重写,也不补造记录。 +- 观察 PR 检查的 GitHub 任务,在检查从通过变为失败时投递一个候选事件,持续失败期间不再投递,通过之后再次失败时再投递一个。 +- 一次推送导致三项检查失败,只产生一个候选事件。 +- 没有变化的观察不写记录,也不启动轮次;不满足条件的变化写一条已过滤记录,不启动轮次;同一原因的连续失败只写一条带计数的记录。 +- 运行记录到投递为止:记录候选事件,不记录该轮次做了什么。 +- 事件轮次不提供 shell、写文件、网页抓取或 MCP 工具,其工具集出现在会话日志中;用户接受卡片后,下一轮次按会话的权限执行。 +- 一次通知调用同时产生卡片、未读标记和桌面端系统通知。 +- 没有调用通知的事件轮次不留下未读标记,也不发送系统通知。 +- 事件任务的只读轮次在任务自己的会话中运行;接受卡片后,建议的操作发往创建任务的会话。 +- 每种会话模式按定义投递:指定的会话接收每次触发,或每次触发新建一个会话,或首次触发时新建一个会话并在之后复用。 +- 回放会话日志可以重建事件轮次的候选事件、工具集和通知调用。 +- 重启后,任务把当前来源状态与保存的状态比较一次,不重放中间的变化。 +- 任务列表可以按来源类型筛选,任务详情以只读方式显示来源参数,会话模式可以修改。 + +## 风险 + +- 只读轮次看到的是指令、本次事件和该任务之前的判断,看不到任务来源的会话。判断需要的、来自那个会话的事实,必须在创建任务时写进指令;用户把任务改成投回原会话,就能拿回历史,代价是每次投递都要读取很长的上下文。 +- 通知出现在任务自己的会话里,而不是创建任务的对话里;接受卡片后,工作转到那个对话。 +- agent 可能通知过于频繁,或者在应该提醒时保持静默。被接受和被忽略的卡片能看出通知过多,但漏掉的通知不会在运行记录中留下痕迹,因为运行记录到投递为止。 +- 来源参数不能手动编辑,每次修改都要经过模型。 +- 针对事件的修复要等用户接受卡片,不会在无人值守时执行。 +- 没有来源提供方插件的来源无法观察。 +- 任务只在 Host 运行时执行:应用关闭或机器休眠期间不运行任何任务。 +- 事件任务依赖操作卡片,而它目前是实验包,只由个性化推荐 Bundle 装入。 +- 只读工具集把判断限制在文件和来源提供方读取操作能看到的内容;来源提供方不提供读取操作时,agent 只能根据事件本身判断。 diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index 0b8b72de99..4455609b71 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -1,76 +1,118 @@ --- name: dsh-client-ui-ux -description: Design and review DeepSeek Harness client UI changes — visual token discipline, reuse-before-adding, feedback surfaces (toast vs in-place notice vs empty state), overlay and menu safety, platform window adaptation, loading states, and error copy placement. Use when adding or changing product-user-visible GUI behavior in packages/client, or when reviewing such a PR. +description: "Design and review DSH user-visible UI: alignment, spacing, icons, visual foundation, geometry and interaction. Use before UI changes in client, experimental and app packages; verify affected rendered results before handoff." --- -# DeepSeek Harness Client UI/UX +# DeepSeek Harness UI development -This skill is guidance, not a complete checklist. It covers judgment calls that lint, typecheck, and the i18n gate cannot make: where feedback appears, what gets reused, and how surfaces behave at window edges and across platforms. Copy quality itself follows [dsh-prose-standard](../dsh-prose-standard/SKILL.md); locale ownership follows the [locale-owned copy decision](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md); styling ownership and token rules live in [docs/web-styling.md](../../../docs/web-styling.md) and the [ui-primitives component catalog](../../../packages/client/ui-primitives/README.md#component-catalog) — this skill adds judgment on top of those rules, not a second copy of them. +Use this skill as the entry point for UI design and review. It owns the workflow, visual foundation, geometry and interaction guidance; alignment, spacing and icon details live in its references below. Load only the references relevant to the task; examples explain judgment, not coverage. -## Architecture prerequisites + +## UI change workflow -Component structure, state flow, and effect usage follow the architecture rules; read them before redesigning a component: +Use applicable design rules for UI changes, including visible loading, error, disabled, expanded and focus states. Choose effort by impact and regression risk; ordinary development does not require a full design audit. -- [packages/client/AGENTS.md](../../../packages/client/AGENTS.md) — Slot and props discipline, reactive read, export, ctx, and layering rules. -- [Client component rules](../../../docs/defensive-patterns/client-components.md) — those requirements stated with the defects they prevent. +| Change | Required inspection | Additional records | +|---|---|---| +| Local copy, icon reuse, color or spacing | Inspect the actual page, affected neighbors and relevant states; run applicable tests. Apply the minimum checks below. | Briefly report results and material gaps. No separate plan, axis drawing, scene inventory, source snapshot or screenshot report is required by default. | +| Page layout, shared components or global styles | Identify affected relationships and consumers; inspect distinct affected layout, content and state variants in assembled pages. Reuse evidence only for justified equivalents. | Briefly state scope. Preserve the starting version and comparable images when requested or needed to explain broad changes or assess regression risk. | +| Explicit comprehensive review | Cover the agreed regions and relevant states; distinguish inspected results, defects and uninspected scope. | Use the [review cookbook](../../../docs/cookbook/reviewing-ui-design.md) for coverage records, findings and supporting evidence. A comparison request alone does not require a product-wide audit. | -## Visual foundation +### Minimum checks for affected UI -- **Use existing tokens for color, radius, and spacing.** Before introducing a literal value, search the target page and its nearest established sibling (the Plugins page is the usual reference for full-page surfaces) for the token or value already in use, and adopt it. The same applies to whole treatments — toasts, menus, in-message artifact previews, sidebar tab layout, colors, clearance rules, page padding: reuse the established component or rule wherever one exists instead of inventing a parallel one. -- **Verify every color in both light and dark mode.** An ink/background pair must stay readable under both themes before it ships. Dimmed tones are the standing counter-example: they suit decorative surfaces, but on text they routinely fall below readability in one theme — never put a dimmed color on text without checking both. -- **Font weight tops out at 500 in feature CSS you add or change.** Do not use 600+ for emphasis; differentiate with size or ink instead. The markdown typography tokens owned by ui-theme are the standing exception. -- **Pick font sizes from the theme typography variables when an existing role matches, otherwise from the page's existing scale.** Parallel content shares one size; never invent a size for a single element, and keep the number of distinct sizes on a page small (13px is the established row/detail size). When the page itself has no precedent, borrow the size and spacing ratios of the most similar existing page or element. -- **Icons, text, and neighbouring elements stay proportionate and aligned.** Every element lines up with its siblings along a deliberate axis (baseline, vertical center, or shared edge), and an icon's size stays in proportion to the text beside it; never ship an element that aligns to nothing or visibly outweighs its neighbours. +These checks apply even to small changes; optional documentation does not make inspection optional. Open the actual page and inspect the changed element with its directly affected neighbors, rather than judging CSS or an isolated component alone. + +- **Color or opacity:** inspect light and dark themes, including affected foreground/background and state combinations. +- **Layout, spacing, typography or sizing:** inspect a constrained container and long or wrapping content; check both sides of a changed breakpoint, alignment, clipping and access to actions. +- **Interaction:** exercise the changed path and affected focus, disabled, loading, error, expanded and overlapping states. Check only states the change can affect, not every possible combination. +- **Shared components or global styles:** trace affected consumers and inspect distinct assembled layouts and states; one isolated example cannot establish coverage. + +If a required scene is unavailable, state what was not checked, why and how it can be verified; a labeled production-component fixture can provide partial evidence, not an actual-page pass. Do not claim verification complete while required checks remain missing. Run the original applicable tests; source inspection and passing tests alone do not establish the rendered result. + +Read unchanged owners once; consult examples for uncertain judgments. Inspect relevant states and actual usage sizes, not a fixed screenshot count or every width × theme × state combination. Batch related edits and reuse a current application instance and valid evidence. Documentation-only changes and refactors with evidence of unchanged rendering need no new UI captures; state that basis. + +When a before/after comparison is needed, preserve the actual starting implementation, including uncommitted work, and use comparable capture conditions. Never recreate a missing before image and present it as an original. Protect existing work regardless of whether extra evidence is needed. + +Before handoff, review or push, check only new cumulative interactions, missing required checks and invalidated evidence; do not repeat completed verification at each stage. Report results and material gaps accurately. Do not claim full coverage or visual acceptance when required scenes or checks are missing. + +These effort levels change only the additional design-review procedure. Existing [client test requirements](../../../packages/client/AGENTS.md), [GUI PR GIF requirements](../record-browser-gif/SKILL.md#every-gui-pull-request-includes-a-gif), and [designer/product approval](#design-review-escalation) still apply, including to local changes when their original triggers are met. + +## Select the rule owners + +For layout changes, read the alignment and spacing references. Read the icon reference when selecting, drawing or reviewing artwork, or when adding, changing or reviewing icon buttons, icon-only actions or informational icons, including reuse without artwork changes. Other tasks use the relevant sections below. Do not load all references by default. Existing responsive and official-artwork skills retain their scopes; numeric scales stay with their implementation owners. + +| Decision needed before implementation | Section or reference | +|---|---| +| Shared axes, text starts, centering, tree indentation, complete icon boxes | [Alignment](references/alignment.md) | +| Semantic groups, inner/outer gaps, repeated rhythm, section separation | [Spacing and grouping](references/spacing.md) | +| Interface icon selection and artwork; icon buttons and icon-only actions, tooltips, informational hover content, accessible labels and focus | [Icons](references/icons.md) | +| Color, typography and existing treatments | [Visual foundation](#visual-foundation); [Web styling](../../../docs/web-styling.md) owns tokens/materials | +| Component geometry, nested contours and state fills | [Geometry decisions](#component-geometry); [radius standard](../../../docs/ui-radius.md) owns values and exceptions | +| Feedback lifetime, loading, overlays, scrolling and platform clearance | [Interaction and feedback](#interaction-and-feedback); [error handling](../dsh-error-handling/SKILL.md) owns failure guarantees | +| Responsive layout rules, mobile-specific interactions, and truncation/ellipsis rules for components like text and buttons | [Responsive UI](../dsh-responsive-ui/SKILL.md) | +| Official plugin artwork and feature previews | [Official artwork](../dsh-official-plugin-artwork/SKILL.md) | +| Copy, locale ownership and component semantics | [Prose standard](../dsh-prose-standard/SKILL.md), [locale ownership](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md), [primitives catalog](../../../packages/client/ui-primitives/README.md#component-catalog) | +| Density, information hierarchy, combined control states and component choice | [Principle reference](../../../docs/ui-design-review-cases.md#density) sections 3–6 | ## Reuse before adding -- **Extend an existing component, container, or interaction before creating a new one.** A new capability that fits an existing surface (an extra menu item, a new prop on an existing primitive) beats a parallel element stacked beside it. -- **Icons come from the existing icon library.** Pick the semantically closest existing icon; a new glyph requires designer approval before it lands — when reviewing a PR that adds one, suggest requesting a designer review. -- **Right-sidebar content registers a slot in the existing sidebar**, never a separate sidebar. Every sidebar tab declares a tab icon matching its meaning. -- **Icon-only actions whose meaning is not obvious get a Tooltip.** Informational content the pointer must rest on or select uses HoverCard instead. Prefer these shared primitives over ad-hoc title attributes. +Extend existing components, containers and interactions when the capability fits. Register right-sidebar content through a slot in the existing sidebar; do not create a separate sidebar. Follow [icon reuse](references/icons.md#reuse-existing-icons-first). Before restructuring, read [client instructions](../../../packages/client/AGENTS.md) and [client defensive patterns](../../../docs/defensive-patterns/client-components.md) for component/state ownership and layering. -## Feedback surfaces +## Visual foundation -Apply [dsh-error-handling](../dsh-error-handling/SKILL.md#localized-actionable-visible-reports) to failure reports. Choose the surface by the lifetime of the message relative to the surface that produced it: +- Reuse tokens, values and complete treatments from the target page or nearest established sibling before adding literals; Plugins is the usual full-page reference. +- Verify colors in light and dark themes; keep ink/background pairs readable, especially dimmed text. +- Feature CSS you add or change uses **font weight ≤500**; emphasize with size or ink. ui-theme markdown typography tokens are the exception. +- Use theme typography roles, then the page's scale, then a comparable page's size/spacing ratios. Do not invent a size for a single element. Peers share sizes; keep the scale small (13px is the established row/detail size). +- Apply [alignment](references/alignment.md) and the [same-foreground rule](references/icons.md#placement-and-use) to icon/text relationships, including value chevrons and search prompts. -- **Transient operation outcomes use the app-wide Toast primitive**, not in-place notices, and the toast's state and rendering live in a host that outlives the reporting surface (a `shell.overlay` entry, as RowActionToast does) — a toast rendered by the panel itself unmounts with that panel. A deletion can close the panel or tab that requested it; only a toast held outside that surface survives the close. Report success and failure; do not report still-pending states. -- **A failed operation keeps the data visible.** A failed deletion keeps its row; the toast announces the failure. Never blank content to show an error for a transient operation. -- **In-place notices are for states tied to the surface itself**: a query failure with its Retry action, field validation on the form that owns the field. A populated panel keeps a compact notice beside its retained content; only an empty panel centers a full error state. -- **Error copy is plain language, and short.** No internal jargon or technical nouns unless an error code must be exposed for debugging. Prefer one clear sentence; anything explainable in two sentences must not grow into paragraphs. A Chinese notice of at most two sentences omits the trailing 句号(。). The notice must not break the existing layout — reserve its space or overlay it; never shift neighbouring elements. +## Component geometry -## Loading states +Choose component role and size variant, then its container relationship. Compare height, radius, curve, inset and material together; equal radii alone do not establish coherence, and wrapping/expansion alone does not justify larger radii. [Radius standards](../../../docs/ui-radius.md#radius-scale) own values and exceptions. -- Lists use their skeleton; every other page-level load centers a bare spinner. Never place a spinner in a corner of the page. -- One loading treatment per page/component — concurrent regions must not each flash their own style. -- No loading text unless the context needs it (narrow sidebars almost never do). +- **Inset:** coordinate inner/outer curves and inset. Radius subtraction is exact for concentric circular arcs; for superellipses, subtraction is only a nominal sizing rule, so inspect diagonal whitespace as well as side gaps. +- **Flush or adjoining:** the enclosure owns exposed corners; internal joins stay square. Hover fills and pseudo-elements follow that contour without clipping menus or focus. +- **Independent child card/group:** use its own role/size tier, not the dialog radius or subtraction of all parent padding. +- **Circles/capsules:** preserve shape, including avatar container and image, using the theme's round-curve exception; ordinary controls use the shared curve. -## Overlays and menus +Keep fills, strokes and overlays geometrically stable across hover, pressed, selected, loading and disabled states. Reuse [card/elevation materials](../../../docs/ui-radius.md#settings-cards): shadows already include neutral hairlines; retain semantic state-colored borders without doubling neutral edges. -Every menu, popover, and tooltip must be verified for all three before merge: +Compare related controls, including small selection controls, at actual size in affected themes/states and relevant long-label/narrow layouts. Static checks reject unsupported declarations; rendered comparison judges fit. Consult [geometry cases](../../../docs/ui-design-review-cases.md#geometry) when needed. Apply to scoped changes, not bulk restyling. -- **Dismissable**: outside click (and Escape where focusable) closes it. -- **Viewport-fitting**: it slides or flips to stay inside the window with an edge margin, and flips only into a side that actually fits (no oscillation when neither side fits). -- **Unclipped**: it escapes `overflow` ancestors and containing blocks (portal to body where needed) and is never covered by neighbouring elements. +## Interaction and feedback -Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge. +### Feedback surfaces -## Responsive layouts +Follow [error handling](../dsh-error-handling/SKILL.md#localized-actionable-visible-reports). Choose feedback by message lifetime: -Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, and focus during width changes. +- Transient success/failure uses the app-wide Toast, with state and rendering in a host that survives the reporting panel/tab, such as a `shell.overlay` entry. A deletion may close its source panel; a Toast owned by that panel would disappear with it. Do not toast pending states. +- Failed operations retain visible data; failed deletion keeps the row. +- Surface-owned failures use in-place notices: query failure with Retry or field validation. Keep a compact notice beside populated content; only empty panels center a full error state. +- Use short plain-language errors, preferably one sentence, without internal jargon except necessary diagnostic codes. Chinese notices of at most two sentences omit the trailing 句号(。). Reserve space or overlay notices so neighbors do not shift. -## Platform window adaptation +### Loading states -- Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec. -- State the intended total offset (clearance + page inset) when reviewing, so two pages aligning to "the same top spacing" agree on the sum, not just one addend. +Lists use skeletons; other page-level loads center a bare spinner. Keep one loading treatment per page/component, including concurrent regions. Add loading text only when context requires it. -## Spacing review +### Overlays and menus -- Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing. -- Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value. -- **For complex pages and containers, establish shared horizontal and vertical alignment axes before adjusting local spacing.** Consider hover, selected, and expanded/collapsed states, as well as the overall relationship with adjacent panels and other page elements. Choose from the following references according to layout similarity: - - **Sidebar:** align corresponding text starts and leading icons across vertically stacked rows. Align hover and selected row backgrounds with the buttons above the list to maintain visual balance. - - **Plugins page:** keep content left-aligned along a shared edge. List-row hover backgrounds may extend beyond that edge while the content stays in place. - - **Conversation and right-sidebar headers:** align the first header rows across both panels and match their heights. Apply the same rule to the second rows, and align corresponding underlines and dividers on shared horizontal baselines. +Before merge, verify every menu, popover and tooltip closes on outside click (and Escape when focusable), fits the viewport with an edge margin, and escapes ancestor clipping, containing blocks and neighbor coverage, using a portal to body when needed. Flip only into a side that fits; never oscillate when neither fits. + +Use shared scrollbar styles and a small inset; bars must stay inside containers without being clipped by rounded corners or edges. + +### Platform window adaptation + +New headers/pages account for macOS and Windows chrome. Menu, Modal and overlay primitives consume `--dsh-frame-top-clearance`; fix local macOS insets with scoped `[data-platform='darwin']` overrides and stylesheet assertions, not global-variable changes. Review the total clearance + page inset. + +## Check the rules together + +Inspect the composition, then affected internal/cross-group relationships, distant peers and expanded content. Match both endpoints of axes; compare complete icon boxes before optical adjustments. Preserve popup/focus visibility, field feedback, narrow-layout actions and drafts. Check affected theme materials/platform backdrops. Full-interface claims cover all regions; fixtures/crops establish only what they render. + +## Maintain one owner per rule + +Update the owning section or reference within this skill; do not expose its design specialties as separate skills. Keep examples in the [case reference](../../../docs/ui-design-review-cases.md); AGENTS and PR workflows link here instead of duplicating rules. + +Rule changes state applicability, decisions, exceptions and observable verification; update a distinguishing case and remove superseded statements elsewhere. Declare migration versus future-edit scope without implying existing UI conforms. Automate only mechanically decidable checks. Local review tools consume repository rules and retain source/rule revisions rather than maintaining another standards copy. ## Design review escalation diff --git a/.agents/skills/dsh-client-ui-ux/references/alignment.md b/.agents/skills/dsh-client-ui-ux/references/alignment.md new file mode 100644 index 0000000000..029e7768c7 --- /dev/null +++ b/.agents/skills/dsh-client-ui-ux/references/alignment.md @@ -0,0 +1,32 @@ +# UI alignment + +Apply within the [UI development loop](../SKILL.md#required-review-loop). Align comparable peers, including slots for optional icons and actions. + +## Layout structure first, visual calibration second + +Distinguish click target, painted button, complete icon box and drawn contour. Intentional icon whitespace belongs to the layout box; do not remove it merely because painted pixels miss a guide. Enlarging a target must not move that box. Check containers, slots, columns, gaps and unintended overrides before optical corrections. Neither matching CSS boxes nor aligning every painted contour alone establishes balance. + +A glyph inconsistent across uses belongs to its icon owner. A composition-specific optical correction needs a scoped justification and comparison with peers. Preserve intended hierarchy: a child icon may align with parent text, but DOM nesting alone does not determine indentation. When comparing a bare icon with a bordered control, extra hit padding is not a shared visible edge. + +Inline SVG `vertical-align: middle` follows font baseline/x-height, not visible text center. Check font, size, line height and icon box across CJK, Latin, monospace and wrapping. Do not make multiline links unwrappable just to center an icon. + +## Choose both the object and the axis + +Name the aligned object and reference container. Equal padding need not align text starts; equal SVG dimensions need not align visible centers. For a close control beside footer actions, explicitly choose the control edge or complete icon-box edge; do not switch between them while measuring. + +| Alignment | Use it for | Judge the actual relationship | Common mistake | +|---|---|---|---| +| Left edge | Navigation labels, list titles, field labels, reading content | Same-role text starts on one deliberate axis, including across groups | Every component has its own plausible but different inset | +| Horizontal center | A standalone primary button or short empty-state stack | Center the icon-plus-label or full stack in the named container | Center the label alone and attach an icon outside it | +| Vertical center | A single-line icon/label row or peer toolbar controls | Visible centers balance; the whole clickable unit keeps a stable height | Equal CSS heights hide different SVG whitespace or line boxes | +| Baseline / first line | Mixed text sizes, multiline labels with leading icons | Text baselines agree; an icon belongs to the intended first line | Center an icon beside the entire multiline paragraph | +| Two edges | Title/action rows and left/right composer groups | Left group follows the content edge; right group follows the trailing edge | Distribute every icon across the available width | +| Repeated columns | Action clusters, timestamps, file rows | Corresponding slots share centers or edges across rows | Only the last icon aligns; preceding icons drift | + +For Chinese/English navigation, identify leading icon centers, text starts and trailing controls by role. Headings, avatars and expandable search may differ. Continue axes only where the composition establishes them, including shared plugin reading edges even when hover paint extends beyond them, and conversation/right-sidebar header heights and baselines. + +For a trigger and its menu, or search field and result rows, compare the complete text inset: outer padding, borders, leading slot and gap. Different icon sizes can retain one text start. For adjacent statistics, check font family, size, weight and line height together before moving the icon. + +Center a button within its own bounds, a welcome group within the main pane, and an empty state within the content remaining below headings/filters. A fixed top margin is not centering. When a short window cannot fit the group, preserve scroll access with a compact top-aligned layout instead of clipping. + +Normalize peer control sizes and slots before any necessary small optical offsets; verify comparable uses. Preserve usable, non-overlapping hit targets and do not hide inconsistent parent layout with transforms. Transparent hit padding can exceed the painted icon slot; check neighboring target edges after tightening a cluster. Check [spacing](spacing.md) without moving the chosen axes; consult [cases](../../../../docs/ui-design-review-cases.md#alignment) for uncertain judgments. diff --git a/.agents/skills/dsh-client-ui-ux/references/icons.md b/.agents/skills/dsh-client-ui-ux/references/icons.md new file mode 100644 index 0000000000..5d9bbefb19 --- /dev/null +++ b/.agents/skills/dsh-client-ui-ux/references/icons.md @@ -0,0 +1,60 @@ +# UI icons + +Apply within the [UI development loop](../SKILL.md#required-review-loop), in the affected page or modal context. + +## Reuse existing icons first + +Search the library and comparable UI for the intended meaning and supported variants. Reuse the shared component; do not copy its SVG into a feature or create a near-duplicate. Size, color and placement mismatches belong to the variant or consumer layout, not a new drawing. + +Draw only when existing artwork cannot express the meaning or the user requests a distinct design. Before drawing, explain the closest alternatives and the unmet meaning or requested design difference. New glyphs require designer approval before landing; requested drafts can be prepared locally. Do not redraw a shared glyph to fix one consumer. + +The [primitives icon contract](../../../../packages/client/ui-primitives/README.md#controls-and-icons) and icon source own available variants, stroke scaling and menu sizes; [official artwork](../../dsh-official-plugin-artwork/SKILL.md) owns package artwork. + +## System style + +- Use minimal, geometric, functional glyphs with familiar meanings. Match peers in contour, endings, joins, weight, whitespace and complexity; avoid decorative, playful or illustration-like detail. A supplied reference guides the requested meaning and distinctive form within this system. +- Use simple geometry and restrained rounding. Around 1px at a 16px design size is a starting point for softened corners, not a rule for every turn. Preserve useful sharp corners. +- Default to monochrome outlines inheriting `currentColor`, without gradients, shadows, textures or 3D effects. Preserve intentional fills, status dots, selected marks and knockouts. File, site, plugin and brand artwork retain their own palettes and roles. +- Balance structure and whitespace without requiring symmetry; avoid accidental tilt or a top-heavy silhouette. Preserve meaningful direction. Use gaps, overlap, nesting and broken obscured lines to clarify complex meanings; avoid accidental tangencies, dark knots and disappearing gaps at actual size. Add contour detail only when it improves recognition; do not force every subject into a closed outline. + +## Optical size and clarity before implementation + +Use the owning control's supported size and weight. [Shared menu sizes and stroke scaling](../../../../packages/client/ui-primitives/README.md#controls-and-icons) belong to primitives; [permission triggers](../../../../packages/client/ui-permission-presets/README.md) and [settings navigation](../../../../packages/client/ui-settings-general/README.md) belong to their feature packages. A reviewed proposal for a larger or heavier trigger is not a statement that every existing consumer already uses it. Compare at the owning control's size, weight, colors and label, using an outlined circle and rectangular control as references. Open silhouettes may need more extent than dense fills; supporting chevrons, handles and dots keep their roles. Do not normalize by ink area or fill every view box. + +A shared view-box change affects every consumer at its own CSS size. Inspect both compact menus and larger icon-only controls; a clearer small chevron can become oversized in a floating button. Adjust a consumer’s supported icon size when needed while retaining its button base and hit target. + +For softness, check CSS dimensions and ancestor transforms, view box and drawing bounds, stroke versus filled contour, device-pixel placement, then color/opacity. Non-16 view boxes are valid; compare identical artwork before attributing sharpness to inline SVG versus sprites. + +Use the [implemented stroke behavior](../../../../packages/client/ui-primitives/README.md#controls-and-icons): source stroke widths scale with the SVG viewport, and filled outlines scale as geometry. Do not add non-scaling strokes or convert filled contours during an ordinary placement or reuse fix. A change to that rendering contract belongs to a separately scoped library change with consumer validation; retain intentional silhouettes and knockouts. + +Check straight-stroke edges at actual DPR after all scaling: center and width jointly determine coverage. Integer source coordinates alone do not guarantee crisp edges. Round positioned overlays in device pixels only when layout places otherwise crisp artwork between pixels; preserve anchoring and viewport containment. Keep antialiasing on curves/diagonals; do not globally round paths or use `crispEdges` to hide placement errors. Partial coverage is legitimate for 1.3px strokes. + +## Draw a new icon + +Establish meaning, placement, actual sizes, peers and states; ask only when missing information changes the design. Produce editable SVG; shared line icons need Regular and Medium variants sharing construction; intentional fill-only variants may share geometry. Tune silhouette, gaps, center and weight at intended size using component/theme conventions, not consumer-specific stroke or scale overrides. + +Deliver editable artwork and a rendered peer comparison, including its label or control when relevant. Explain the semantic choice and unresolved alternatives. Keep drafts local; when implementing, register reusable components and update their documentation. Distinguish review-ready proposals from approved glyphs. + +## Normalize user-supplied artwork + +Before changing supplied artwork, establish its meaning, recognizable features and intended use. Preserve the original and recognizable form. Show an existing equivalent as a reference when available, but do not substitute it for the drawing the user asked to normalize. Separate technical fixes (canvas, scale, stroke, color inheritance, placement) from silhouette or metaphor changes; identify any required simplification. + +Apply the same system style and verification criteria as new artwork. Inspect SVG paths, transforms, fills, strokes and references. Reconstruct raster references without tracing rasterization or compression artifacts; identify interpreted details and ask about unrecoverable defining features. Deliver editable SVG and original/normalized/peer comparisons under identical conditions at intended size. Keep artwork changes separate from consumer layout fixes. + +## Placement and use + +Distinguish click target, visible button base, icon slot and painted glyph. Equal targets or SVG dimensions do not imply equal optical size; compare silhouette, stroke, whitespace and center beside the label. Fix containers and unintended CSS overrides first. Correct shared artwork only when the imbalance follows it across uses. + +An icon and its associated text for one action, value or input prompt must share foreground color, effective opacity and state changes. This includes submenu value chevrons and search placeholders; inherit the shared role instead of assigning an independent icon token. Typed text and independent clear actions may differ; status, warning and brand roles retain semantic colors. Check computed color and ancestor opacity in both themes. Apply the rule to changed consumers; updating it does not certify untouched UI. Compare native file icons and folder outlines deliberately; do not normalize logos, status dots or decorative illustrations to toolbar icons. + +Every right-sidebar tab needs a meaningful icon. [Alignment](alignment.md) owns positioning and [spacing](spacing.md) owns gaps. + +## Icon controls and interaction + +Use the shared Tooltip for unclear icon-only actions and HoverCard for informational content requiring hover or selection; prefer these primitives over ad-hoc `title` attributes. Preserve accessible labels and focus. [Responsive UI](../../dsh-responsive-ui/SKILL.md) owns mobile tooltips and label collapse. + +## Verification and handoff + +For ordinary icon reuse or placement changes, inspect the affected control at its actual size and relevant states; no fixed size/DPR matrix or separate raster report is required. For new or changed artwork, strokes or rendering, compare affected sizes and weights with peers; check DPR 1 and 2 when stroke scaling or pixel placement can change, and both themes when color or optical weight is affected. Keep before/after captures when needed for the change or requested deliverable. Judge recognition, clipping, center, gaps and neighboring text at actual size; enlarged views diagnose pixels only. + +After a set-wide change, trace affected families, consumers and weights, including unchanged artwork whose rendering can change. Use unchanged peers for comparison. An explicitly requested full-set review inventories and compares every glyph under matching conditions. Consolidate duplicate artwork only when necessary for the current fix; record unrelated duplicates separately instead of expanding the task. Consult [cases](../../../../docs/ui-design-review-cases.md#icon-optics) for uncertain judgments. diff --git a/.agents/skills/dsh-client-ui-ux/references/spacing.md b/.agents/skills/dsh-client-ui-ux/references/spacing.md new file mode 100644 index 0000000000..b2d173692f --- /dev/null +++ b/.agents/skills/dsh-client-ui-ux/references/spacing.md @@ -0,0 +1,19 @@ +# UI spacing and grouping + +Apply within the [UI development loop](../SKILL.md#required-review-loop). Distance should explain which text, input and action belong together. + +### Identify the group before choosing the gap + +Group elements by one object, action or repeated unit, not DOM siblings, background or React component. State that relationship before positioning. Check each nesting level independently; larger outer gaps do not require larger inner gaps. + +For example, a title and description introduce one section; three conversation actions form one operation group; member cards form a section separate from tasks. In a model form, checkbox and label form an option, options form a field, fields form a model, and models form a list. Check each relationship rather than applying one gap everywhere. + +Without an explicit divider, comparable neighboring groups need visibly greater separation between groups than within them. Equality is only a lower bound against reversal. Repeated compositions share gaps, padding and alignment; label length does not justify unrelated spacing. + +When wrapping, metadata, subgroups or independent actions increase complexity, increase enclosing separation enough to retain grouping. A compact icon/label pair and a section of multiline cards need different spacing levels. Do not derive gaps from card area or multiply every margin. Dividers and distinct surfaces may reinforce separation, but headings must remain closer to their own content than to preceding groups. + +Judge actual whitespace at normal size, including padding, line-height, glyph whitespace and borders. CSS margin ratios alone do not establish grouping; explanatory labels need one unambiguous owner. For example, declared 16px/8px margins can still leave a heading closer to the preceding content once line-height and container padding are included; compare the rendered gaps rather than adopting that pair as a rule. + +When matching two visible gaps, name both pairs of endpoints and include each container’s padding, border and text line box. Equal visible gaps may require unequal margins. A control’s painted edge and a text glyph’s edge are different endpoints; keep that choice stable through review. + +Reuse page spacing and [style tokens](../../../../docs/web-styling.md), preserve [alignment](alignment.md) axes, and consult [cases](../../../../docs/ui-design-review-cases.md#grouping) for uncertain judgments. This skill defines no numeric scale. diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index e5cf8c079f..2be16611a8 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -39,6 +39,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — ## Manual checks +- **UI review coverage:** apply the [UI change workflow](../dsh-client-ui-ux/SKILL.md#required-review-loop) to the cumulative visible diff. Verify its [minimum checks](../dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui) for the changed properties and consumers. Missing required inspection remains a gap; missing optional reports does not. Do not demand full-audit records for ordinary changes. Recheck only new interactions or invalidated evidence. Existing tests, GUI GIFs and designer/product approval remain required under their original triggers. - **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns/general.md](../../../docs/defensive-patterns/general.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. diff --git a/.agents/skills/dsh-cua-macos-signing/SKILL.md b/.agents/skills/dsh-cua-macos-signing/SKILL.md new file mode 100644 index 0000000000..6d28147f29 --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/SKILL.md @@ -0,0 +1,78 @@ +--- +name: dsh-cua-macos-signing +description: Proactively recommend a personal fixed signing key to macOS DSH developers using Computer Use from source, rebuilding the helper, or switching worktrees, even before they ask about repeated permissions. After they choose it, generate or reuse the key outside DSH home and configure its path. Also handles unlocking and removal; installed release users do not need this setup. +--- + +# macOS CUA development signing + +## Recognize developer use and recommend signing + +When a macOS developer uses DSH Computer Use from a source checkout, proactively recommend a reusable personal signing identity. Relevant signals include launching DSH from source, rebuilding the helper, preparing another worktree, or investigating repeated permission prompts. The developer does not need to be editing CUA code, and does not need to ask about certificates first. Before a cross-worktree permission-retention test, establish that both builds select the same fixed identity. + +Use the task context and available configuration to choose the recommendation. If an identity is already configured, recommend reusing it and unlock it when needed; do not propose another key for each worktree. If signing is unset or `ad-hoc`, briefly explain that code-hash changes can require fresh grants, recommend creating one local identity under `~/.cua-macos-keys/`, and offer to configure `cordis.patch.yml` to select it. Mention that the first macOS grant remains manual. Follow the authorization rule below before creating keys or editing configuration; a recommendation alone does not authorize setup. + +For example: “Since you are using Computer Use from source and switching worktrees, I recommend creating one personal signing identity and reusing it across builds to reduce repeated authorization. I can generate it under `~/.cua-macos-keys/` and configure this DSH home to use it. You will still grant macOS permissions once.” Adapt the wording to the user's language and task. + +Do not recommend developer keys merely because a user is using the installed DSH app, working on an unrelated repository, or developing without Computer Use. If they choose to keep ad-hoc signing, continue their task and do not repeat the recommendation without a relevant change in circumstances. + +## Understand the signing choices + +Source builds default to `sourceSigning: ad-hoc`. They need no developer certificate. An ad-hoc designated requirement includes the code hash, so changed binaries, clean rebuilds, or different worktrees can require fresh Accessibility and Screen Recording grants even when their sources are unchanged. For developers who want fewer repeated grants, configure one fixed certificate and use it across builds. The first grant is still manual; verify actual permission retention rather than claiming that matching signatures alone prove it. + +The [macOS provider](../../../packages/experimental/cua-macos/README.md#use-this-package) owns the two configuration choices: `ad-hoc`, or `{ identity, keychain }`. The build consumes an existing certificate; it never creates or unlocks one. Developer key material stays outside DSH home. DSH home stores only the configuration selecting it. + +## Create or reuse an identity + +Run from a repository checkout on macOS with the selected Apple command-line tools. An explicit request to set up developer signing or reduce repeated development grants authorizes setup and the corresponding local configuration edit. For advice-only requests, explain the choices without creating an identity. + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup +``` + +The script creates or reuses `~/.cua-macos-keys/dev/signing.keychain-db`, stores its password and certificate fingerprint in the adjacent `identity.json` with mode `0600`, and keeps the directory private. It adds only its dedicated keychain to the user search list and enables `codesign` access without prompting for the login password. A sibling `dev.lock` serializes setup and removal across worktrees; custom directory names keep their full name before `.lock`. Supply `--directory ` when the developer chose another location outside DSH home and the checkout. + +Setup claims only a new or empty directory and writes `.dsh-cua-macos-signing` as its ownership marker. Setup and removal refuse directories without that marker, unexpected entries, or symlinked identity files before changing keychains or identity files. Preserve unmarked identities from earlier tooling; do not add a marker automatically. They can still be selected as existing user keychains. + +Setup prints only the certificate fingerprint and absolute keychain path as JSON. Keep the password record out of chat, logs, commits, and configuration. Other processes running as this user can read the stored password and use this development identity; this is the local developer setup's access model. + +If an existing record is damaged or its keychain/certificate is missing, setup stops. Do not silently replace that identity: replacement changes the certificate and can require new grants. Follow the reported remove/setup commands only when identity replacement is authorized. + +## Configure the source build + +Resolve the DSH home used by the developer (`DSH_HOME`, otherwise `~/.dsh`). Merge the two printed values into its `cordis.patch.yml`, targeting the entry that loads `@deepseek-ai/dsh-experimental-cua-macos`. The shipped entry ID is `cua`: + +```yaml +- id: cua + config: + sourceSigning: + identity: "" + keychain: "/Users//.cua-macos-keys/dev/signing.keychain-db" +``` + +Use the printed absolute path, not a literal `~`. Preserve unrelated patches and every existing field in this entry's effective config: a Cordis patch replaces the complete `config` object rather than deep-merging it. If the provider has `helperPath`, source signing is bypassed; remove that override only when the developer intends to test the source-built helper. Ensure the profile actually loads the provider before adding a patch for its ID. + +Launch through the developer's existing `dsh` profile. The home-level patch shares the selection across profiles and worktrees that use that home. Other DSH homes can explicitly select the same certificate. Record the paths changed and the selected public fingerprint, never the password. + +## Unlock and verify + +After a restart or a manual keychain lock, rerun `setup`; it unlocks and reuses the existing identity. Do not ask the developer for the login-keychain password or store one. A source build does not automatically unlock the keychain and can fail or prompt if it is locked. + +Grant Accessibility and Screen Recording to `DSH Computer Use Dev.app` in System Settings. Call `getCapabilities`, change and rebuild the Swift helper, then test another worktree with the same configuration. Report the observed permission flags for each step. Matching `codesign -d -r- ` requirements confirm signature continuity but do not replace the permission checks. + +For a temporary, permission-free signing mechanism check, run: + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py +``` + +It creates an isolated identity, tests reuse and unlocking, signs changed binaries, then removes its keychain and checks search-list restoration. Run it without concurrent keychain-list mutations. + +## Remove an identity + +First remove the selecting configuration or set `sourceSigning: ad-hoc` in each affected home. For an authorized removal, run: + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove +``` + +Use the same `--directory` for a custom location. Removal deletes only the marked identity’s files and its search-list entry, then removes the empty directory; the empty sibling lock remains so waiting processes still coordinate. Deleting DSH home alone leaves the developer identity intact. System Settings permission entries are separate; remove them only when requested. diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py b/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py new file mode 100644 index 0000000000..71c36752d5 --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py @@ -0,0 +1,77 @@ +"""Real development signing keeps one designated requirement across changed builds. + +Creates a temporary development identity, adds its keychain to the user search list for the duration +of the check, and removes both afterwards. No system permission or password dialog is requested. +""" +import importlib.util +import json +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile + +NATIVE = Path(__file__).resolve().parents[4] / "packages/experimental/cua-macos/native" + + +def module(name): + spec = importlib.util.spec_from_file_location(name, NATIVE / "build_app.py" if name == "build_app" else Path(__file__).with_name("signing_identity.py")) + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +def requirement(application): + output = subprocess.run(["/usr/bin/codesign", "-d", "-r-", str(application)], check=True, capture_output=True, text=True) + return next(line.split("=> ", 1)[1] for line in (output.stdout + output.stderr).splitlines() if "designated =>" in line) + + +def main(): + builder, signing = module("build_app"), module("dev_signing") + before = signing.user_keychains() + with tempfile.TemporaryDirectory(prefix="cua-dev-signing-e2e-") as temporary: + root = Path(temporary) + directory = root / "keys/dev" + try: + command = [sys.executable, str(Path(__file__).with_name("signing_identity.py")), "setup", "--directory", str(directory)] + configuration = json.loads(subprocess.run(command, check=True, capture_output=True, text=True).stdout) + identity = signing.setup(directory) + assert configuration == {"identity": identity.sha1, "keychain": str(identity.keychain.resolve())} + assert signing.setup(directory) == identity + sha1, keychain = identity.sha1, identity.keychain + assert (directory / "identity.json").stat().st_mode & 0o777 == 0o600 + signing.security("lock-keychain", str(keychain)) + assert signing.setup(directory) == identity, "setup must unlock an existing developer keychain" + requirements = [] + for status in (0, 1): + (root / "main.c").write_text(f"int main(void) {{ return {status}; }}\n") + subprocess.run(["/usr/bin/xcrun", "clang", "-o", str(root / "helper"), str(root / "main.c")], check=True) + executable = builder.publish(root / "helper", root / f"checkout-{status}", sha1, NATIVE / "Info.plist", "dev", keychain) + application = executable.parents[2] + assert application.name == "DSH Computer Use Dev.app", application + subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(application)], check=True) + info = plistlib.loads((application / "Contents/Info.plist").read_bytes()) + assert info["CFBundleIdentifier"] == "com.deepseek.harness.computer-use.dev", info + requirements.append((application, requirement(application))) + (first, first_requirement), (second, second_requirement) = requirements + assert first != second, "changed builds must publish separate generations" + assert first_requirement == second_requirement, requirements + assert "certificate leaf" in first_requirement and "cdhash" not in first_requirement, first_requirement + subprocess.run(["/usr/bin/codesign", "--verify", f"-R={first_requirement}", str(second)], check=True) + selected = json.loads((root / "checkout-1/current.json").read_text())["bundle"] + assert second.parent.name == selected + for index, (certificate, selected_keychain) in enumerate(((sha1, keychain), (signing.COMMON_NAME, keychain))): + custom = builder.publish(root / "helper", root / f"custom-{index}", certificate, NATIVE / "Info.plist", "dev", selected_keychain) + assert requirement(custom.parents[2]) == first_requirement + print(f"PASS: developer setup reuses and unlocks its identity; configured signatures across changed builds share {first_requirement}") + finally: + try: + signing.remove(directory) + finally: + # This fixture owns the temporary directory even if ownership validation fails. + signing.clear_identity(directory) + assert signing.user_keychains() == before, "the user keychain search list was not restored" + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py b/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py new file mode 100644 index 0000000000..69ab639a9d --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py @@ -0,0 +1,258 @@ +"""Manage an explicitly requested developer identity outside DSH home. + +macOS keeps Accessibility and Screen Recording grants for a bundle identifier together with the +designated requirement of the signature that was granted. An ad-hoc signature's requirement is the +code hash, so every changed build loses the grant. A certificate signature's requirement names the +bundle identifier and the certificate, preserving the requirement across changed builds. + +The identity lives in a dedicated keychain whose password is stored beside it with owner-only +permissions, so developer setup can unlock it without a system dialog. Any process running as the same +user can read that password and sign code that inherits the development grants; the grants belong +to the development bundle identifier only, never to the release helper. +""" +import argparse +from contextlib import contextmanager +from dataclasses import dataclass +import fcntl +import json +import os +from pathlib import Path +import re +import secrets +import shlex +import subprocess +import sys +import tempfile + +COMMON_NAME = "DSH Computer Use Dev (local)" +OWNER_MARKER = ".dsh-cua-macos-signing" +OWNER_VERSION = "1\n" +SECURITY = "/usr/bin/security" +OPENSSL = "/usr/bin/openssl" +SETUP_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup" +REMOVE_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove" +CERTIFICATE_CONFIG = f"""[req] +distinguished_name = subject +x509_extensions = extensions +prompt = no +[subject] +CN = {COMMON_NAME} +[extensions] +basicConstraints = critical,CA:false +keyUsage = critical,digitalSignature +extendedKeyUsage = critical,codeSigning +""" + + +@dataclass(frozen=True) +class SigningIdentity: + """An unlocked certificate identity that codesign can use without user interaction.""" + sha1: str + keychain: Path + + +def private_command(command, **kwargs): + """Run signing tools without exposing secret arguments or their echoed output in diagnostics.""" + operation = f"{Path(command[0]).name} {command[1]}" + try: + return subprocess.run(command, check=True, capture_output=True, text=True, timeout=60, **kwargs).stdout + except subprocess.CalledProcessError as error: + raise RuntimeError(f"{operation} failed (exit {error.returncode})") from None + except subprocess.TimeoutExpired: + raise RuntimeError(f"{operation} timed out after 60 seconds") from None + except OSError as error: + raise RuntimeError(f"{operation} could not run (errno {error.errno})") from None + + +def security(*args): + return private_command([SECURITY, *args]) + + +@contextmanager +def identity_lock(directory): + """Serialize setup and removal across worktrees; the lock inode survives identity removal.""" + directory.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with os.fdopen(os.open(directory.parent / f"{directory.name}.lock", os.O_WRONLY | os.O_CREAT, 0o600), "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def user_keychains(): + """Read the user keychain search list that codesign consults for identities.""" + return [line.strip().strip('"') for line in security("list-keychains", "-d", "user").splitlines() if line.strip()] + + +def listed(keychain, keychains): + """Compare canonical paths; security reports /private/var for /var and resolves other links.""" + return any(os.path.realpath(path) == os.path.realpath(keychain) for path in keychains) + + +def record_path(directory): + return directory / "identity.json" + + +def keychain_path(directory): + return directory / "signing.keychain-db" + + +def default_directory(): + """Keep developer credentials independent of DSH profiles, homes, and worktrees.""" + return Path.home() / ".cua-macos-keys" / "dev" + + +def owned_directory(directory, create=False): + """Refuse unowned or mixed-content directories before changing files or keychains.""" + marker = directory / OWNER_MARKER + if create: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if not any(directory.iterdir()): + directory.chmod(0o700) + with os.fdopen(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file: + file.write(OWNER_VERSION) + if not directory.exists(): + return + if marker.is_symlink() or not marker.is_file() or marker.read_bytes() != OWNER_VERSION.encode(): + raise RuntimeError(f"Refusing unowned signing directory: {directory}; choose a new or empty dedicated directory") + allowed = {OWNER_MARKER, record_path(directory).name, record_path(directory).with_suffix(".tmp").name, keychain_path(directory).name} + for entry in directory.iterdir(): + # security creates empty .flXXXXXXXX locks and removes them with delete-keychain. + system_lock = re.fullmatch(r"\.fl[0-9A-Fa-f]{8}", entry.name) and entry.is_file() and entry.stat().st_size == 0 + if (entry.name not in allowed and not system_lock) or entry.is_symlink() or not entry.is_file(): + raise RuntimeError(f"Refusing signing directory with unrelated entries: {directory}; preserve those entries before retrying") + + +def load(directory): + """Unlock the configured identity before signing. + + Returns None when setup has not run. Raises when setup ran but its keychain is missing or no + longer on the search list, because signing would otherwise fail or prompt for a password. + """ + directory_argument = shlex.quote(str(directory)) + setup_command = f"{SETUP_COMMAND} --directory {directory_argument}" + remove_command = f"{REMOVE_COMMAND} --directory {directory_argument}" + record = record_path(directory) + if not record.exists(): + return None + try: + fields = json.loads(record.read_text()) + valid = isinstance(fields, dict) and isinstance(fields.get("password"), str) and isinstance(fields.get("sha1"), str) and bool(fields["password"]) and bool(fields["sha1"]) + except (ValueError, OSError): + valid = False + if not valid: + raise RuntimeError(f"Computer Use development signing record is invalid; run {remove_command}, then {setup_command}") + keychain = keychain_path(directory) + if not keychain.is_file(): + raise RuntimeError(f"Computer Use development signing keychain is missing: {keychain}; run {remove_command}, then {setup_command}") + if not listed(keychain, user_keychains()): + raise RuntimeError(f"Computer Use development signing keychain is not on the user keychain search list; run {setup_command}") + # A locked keychain makes codesign show a password dialog, so unlock it explicitly every time. + security("unlock-keychain", "-p", fields["password"], str(keychain)) + if fields["sha1"] not in security("find-identity", str(keychain)): + raise RuntimeError(f"Computer Use development signing certificate is missing; run {remove_command}, then {setup_command}") + return SigningIdentity(sha1=fields["sha1"], keychain=keychain) + + +def create_certificate(directory, password): + """Create the key pair inside the dedicated keychain and return the certificate SHA-1.""" + keychain = keychain_path(directory) + with tempfile.TemporaryDirectory(prefix=".certificate-", dir=directory) as temporary: + work = Path(temporary) + (work / "openssl.cnf").write_text(CERTIFICATE_CONFIG) + export_password = secrets.token_hex(16) + for command in ( + ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "key.pem", "-out", "certificate.pem", + "-days", "3650", "-config", "openssl.cnf"], + ["pkcs12", "-export", "-inkey", "key.pem", "-in", "certificate.pem", "-out", "identity.p12", + "-passout", f"pass:{export_password}"], + ): + private_command([OPENSSL, *command], cwd=work) + fingerprint = private_command([OPENSSL, "x509", "-in", "certificate.pem", "-noout", "-fingerprint", "-sha1"], cwd=work) + security("create-keychain", "-p", password, str(keychain)) + security("unlock-keychain", "-p", password, str(keychain)) + # No arguments disables the inactivity timeout and lock on sleep. + security("set-keychain-settings", str(keychain)) + security("import", str(work / "identity.p12"), "-k", str(keychain), "-P", export_password, "-T", "/usr/bin/codesign") + # Without a partition list, codesign asks the user to allow access to the private key. + security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, str(keychain)) + match = re.search(r"=([0-9A-F:]+)", fingerprint) + if match is None: + raise RuntimeError("openssl returned no certificate fingerprint") + sha1 = match.group(1).replace(":", "") + if sha1 not in security("find-identity", str(keychain)): + raise RuntimeError("the imported development signing identity is not usable") + return sha1 + + +def setup(directory): + """Create an identity only without a record; preserve recorded identities when their keychain is missing.""" + with identity_lock(directory): + return setup_locked(directory) + + +def setup_locked(directory): + owned_directory(directory, create=True) + keychain = keychain_path(directory) + if record_path(directory).exists(): + if keychain.is_file(): + keychains = user_keychains() + if not listed(keychain, keychains): + security("list-keychains", "-d", "user", "-s", *keychains, str(keychain)) + return load(directory) + clear_identity(directory) + password = secrets.token_hex(32) + sha1 = create_certificate(directory, password) + security("list-keychains", "-d", "user", "-s", *user_keychains(), str(keychain)) + record = record_path(directory) + with os.fdopen(os.open(record.with_suffix(".tmp"), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file: + json.dump({"sha1": sha1, "password": password}, file) + file.write("\n") + os.replace(record.with_suffix(".tmp"), record) + return load(directory) + + +def remove(directory): + """Delete the keychain, its search-list entry and the stored password.""" + with identity_lock(directory): + remove_locked(directory) + + +def remove_locked(directory): + owned_directory(directory) + clear_identity(directory) + if directory.exists(): + (directory / OWNER_MARKER).unlink() + directory.rmdir() + + +def clear_identity(directory): + """Remove only the identity files and its exact keychain search-list entry.""" + keychain = keychain_path(directory) + keychains = user_keychains() + if listed(keychain, keychains): + security("list-keychains", "-d", "user", "-s", *[path for path in keychains if not listed(keychain, [path])]) + if keychain.exists(): + security("delete-keychain", str(keychain)) + record_path(directory).unlink(missing_ok=True) + record_path(directory).with_suffix(".tmp").unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description="Manage the local signing identity for source-built Computer Use applications.") + parser.add_argument("action", choices=["setup", "remove"]) + parser.add_argument("--directory", type=Path, default=default_directory(), help="identity directory; defaults to ~/.cua-macos-keys/dev") + args = parser.parse_args() + directory = args.directory.expanduser().resolve() + if args.action == "setup": + identity = setup(directory) + print(json.dumps({"identity": identity.sha1, "keychain": str(identity.keychain)}, indent=2)) + else: + remove(directory) + print("Removed the development signing identity; remove its sourceSigning configuration before the next build") + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py b/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py new file mode 100644 index 0000000000..949926f98c --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py @@ -0,0 +1,335 @@ +"""Development signing setup keeps one unlocked identity on the user keychain search list.""" +import contextlib +import io +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("dev_signing", Path(__file__).with_name("signing_identity.py")) +signing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(signing) + + +class FakeSecurity: + """Record security commands and model the user search list.""" + def __init__(self, keychains): + self.keychains = list(keychains) + self.commands = [] + + def __call__(self, *args): + self.commands.append(args) + if args[:3] == ("list-keychains", "-d", "user") and len(args) == 3: + return "".join(f' "{path}"\n' for path in self.keychains) + if args[:4] == ("list-keychains", "-d", "user", "-s"): + self.keychains = list(args[4:]) + if args[0] == "delete-keychain": + Path(args[1]).unlink() + if args[0] == "find-identity": + return "ABC123 NEW456" + return "" + + +class DevelopmentSigningTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cua-dev-signing-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.directory = self.root / "signing" + self.keychain = self.directory / "signing.keychain-db" + self.login = "/Users/example/Library/Keychains/login.keychain-db" + + def configure(self, listed=True): + self.directory.mkdir(mode=0o700) + (self.directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION) + self.keychain.write_bytes(b"keychain") + (self.directory / "identity.json").write_text(json.dumps({"sha1": "ABC123", "password": "secret"})) + return self.fake([self.login, str(self.keychain)] if listed else [self.login]) + + def fake(self, keychains): + security = FakeSecurity(keychains) + patcher = patch.object(signing, "security", security) + patcher.start() + self.addCleanup(patcher.stop) + return security + + def test_setup_and_remove_preserve_unowned_directory_contents(self): + security = self.fake([self.login]) + self.directory.mkdir() + unrelated = self.directory / "notes.txt" + unrelated.write_text("keep this document") + for operation in (signing.setup, signing.remove): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(RuntimeError, "Refusing unowned signing directory"): + operation(self.directory) + self.assertEqual(unrelated.read_text(), "keep this document") + self.assertEqual(list(self.directory.iterdir()), [unrelated]) + self.assertEqual(security.commands, []) + + def test_unrelated_files_in_an_owned_directory_block_setup_and_removal(self): + security = self.configure() + unrelated = self.directory / "notes.txt" + unrelated.write_text("keep this document") + before = {p.name: p.read_bytes() for p in self.directory.iterdir()} + for operation in (signing.setup, signing.remove): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual({p.name: p.read_bytes() for p in self.directory.iterdir()}, before) + self.assertEqual(security.commands, []) + + def test_system_keychain_locks_are_removed_by_security(self): + security = self.configure() + lock = self.directory / ".flD3051040" + lock.touch() + def remove_keychain(*args): + result = security(*args) + if args[0] == "delete-keychain": + lock.unlink() + return result + with patch.object(signing, "security", side_effect=remove_keychain): + signing.remove(self.directory) + self.assertFalse(self.directory.exists()) + + def test_nonempty_files_with_a_system_lock_name_are_preserved(self): + security = self.configure() + lock = self.directory / ".flD3051040" + lock.write_text("unrelated contents") + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual(lock.read_text(), "unrelated contents") + self.assertEqual(security.commands, []) + + def test_missing_or_invalid_marker_cannot_adopt_an_existing_identity(self): + security = self.configure() + marker = self.directory / signing.OWNER_MARKER + for contents in (None, "different tool", "\ufffd"): + if contents is None: + marker.unlink() + else: + marker.write_text(contents) + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unowned signing directory"): + operation(self.directory) + self.assertTrue(self.keychain.is_file()) + self.assertEqual(security.commands, []) + + def test_symlinked_identity_files_do_not_delete_another_directorys_data(self): + security = self.configure() + outside = self.root / "outside.keychain-db" + outside.write_bytes(b"other identity") + self.keychain.unlink() + self.keychain.symlink_to(outside) + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual(outside.read_bytes(), b"other identity") + self.assertEqual(security.commands, []) + + def test_empty_directory_gets_an_ownership_marker_before_generation(self): + self.fake([self.login]) + self.directory.mkdir() + with patch.object(signing, "create_certificate", side_effect=RuntimeError("generation interrupted")): + with self.assertRaisesRegex(RuntimeError, "generation interrupted"): + signing.setup(self.directory) + self.assertEqual((self.directory / signing.OWNER_MARKER).read_text(), signing.OWNER_VERSION) + signing.remove(self.directory) + self.assertFalse(self.directory.exists()) + + def test_removing_the_only_search_entry_leaves_no_unrelated_entries_to_restore(self): + security = self.configure() + security.keychains = [str(self.keychain)] + signing.remove(self.directory) + self.assertEqual(security.keychains, []) + self.assertFalse(self.directory.exists()) + + def test_dotted_identity_directories_keep_separate_lock_files(self): + directories = [self.root / name for name in ("dev", "dev.keys", "keys.v1", "keys.v2")] + for directory in directories: + with signing.identity_lock(directory): + pass + self.assertEqual({p.name for p in self.root.iterdir()}, {f"{p.name}.lock" for p in directories}) + + def test_default_identity_is_outside_dsh_home(self): + home = self.root / "user" + with patch.object(Path, "home", return_value=home): + for value in ("", str(self.root / "custom-dsh-home")): + with patch.dict(signing.os.environ, {"DSH_HOME": value}): + self.assertEqual(signing.default_directory(), home / ".cua-macos-keys/dev") + self.assertFalse(home.exists()) + + def test_setup_and_remove_target_the_explicit_identity_directory(self): + directory = self.root / "keys/dev" + identity = signing.SigningIdentity("ABC123", directory / "signing.keychain-db") + for action in ("setup", "remove"): + with patch.object(signing.sys, "argv", ["signing_identity.py", action, "--directory", str(directory)]), \ + patch.object(signing, action, return_value=identity) as operation, contextlib.redirect_stdout(io.StringIO()) as output: + signing.main() + operation.assert_called_once_with(directory) + if action == "setup": + self.assertEqual(json.loads(output.getvalue()), {"identity": "ABC123", "keychain": str(identity.keychain)}) + + def test_missing_setup_leaves_builds_unconfigured(self): + self.fake([self.login]) + self.assertIsNone(signing.load(self.directory)) + + def test_load_unlocks_the_keychain_before_returning_the_identity(self): + security = self.configure() + identity = signing.load(self.directory) + self.assertEqual((identity.sha1, identity.keychain), ("ABC123", self.keychain)) + self.assertIn(("unlock-keychain", "-p", "secret", str(self.keychain)), security.commands) + + def test_listed_keychains_match_through_symbolic_links(self): + (self.root / "alias").symlink_to(self.root, target_is_directory=True) + self.configure() + signing.security.keychains = [self.login, str(self.root / "alias/signing/signing.keychain-db")] + self.assertEqual(signing.load(self.directory).sha1, "ABC123") + + def test_unlisted_or_missing_keychains_fail_with_the_setup_command(self): + security = self.configure(listed=False) + with self.assertRaisesRegex(RuntimeError, "search list; run python3"): + signing.load(self.directory) + self.keychain.unlink() + with self.assertRaisesRegex(RuntimeError, "keychain is missing"): + signing.load(self.directory) + self.assertFalse(any(command[0] == "unlock-keychain" for command in security.commands)) + + def test_setup_relists_an_existing_identity_without_creating_another(self): + security = self.configure(listed=False) + with patch.object(signing, "create_certificate") as create: + self.assertEqual(signing.setup(self.directory).sha1, "ABC123") + create.assert_not_called() + self.assertEqual(security.keychains, [self.login, str(self.keychain)]) + + def test_setup_replaces_only_an_incomplete_owned_identity(self): + security = self.configure() + (self.directory / "identity.json").unlink() + def create(directory, password): + self.keychain.write_bytes(b"new keychain") + return "NEW456" + with patch.object(signing, "create_certificate", side_effect=create): + identity = signing.setup(self.directory) + self.assertEqual(identity.sha1, "NEW456") + self.assertEqual(self.keychain.read_bytes(), b"new keychain") + self.assertEqual(oct((self.directory / "identity.json").stat().st_mode & 0o777), "0o600") + self.assertEqual(oct(self.directory.stat().st_mode & 0o777), "0o700") + self.assertEqual(security.keychains, [self.login, str(self.keychain)]) + + def test_setup_preserves_the_record_when_the_keychain_is_missing(self): + security = self.configure() + record = self.directory / "identity.json" + before = record.read_bytes() + self.keychain.unlink() + with patch.object(signing, "create_certificate") as create: + with self.assertRaisesRegex(RuntimeError, "keychain is missing: .*; run .*remove --directory .*, then .*setup --directory "): + signing.setup(self.directory) + create.assert_not_called() + self.assertEqual(record.read_bytes(), before) + self.assertFalse(self.keychain.exists()) + self.assertEqual(security.commands, []) + + def test_setup_cli_preserves_a_recorded_identity_missing_its_keychain(self): + directory = self.root / "keys/dev" + directory.mkdir(parents=True) + (directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION) + record = directory / "identity.json" + before = json.dumps({"sha1": "ABC123", "password": "secret-password"}).encode() + record.write_bytes(before) + result = subprocess.run([sys.executable, str(Path(signing.__file__)), "setup", "--directory", str(directory)], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertIn("keychain is missing", result.stderr) + self.assertIn(f"run {signing.REMOVE_COMMAND} --directory {directory}, then {signing.SETUP_COMMAND} --directory {directory}", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn("secret-password", result.stderr) + self.assertEqual(record.read_bytes(), before) + self.assertFalse(signing.keychain_path(directory).exists()) + + def test_remove_restores_the_other_search_list_entries_and_deletes_the_password(self): + security = self.configure() + signing.remove(self.directory) + self.assertEqual(security.keychains, [self.login]) + self.assertFalse(self.directory.exists()) + signing.remove(self.directory) + self.assertEqual(security.keychains, [self.login]) + + def test_missing_certificate_requires_explicit_removal_instead_of_changing_identity(self): + self.configure() + (self.directory / "identity.json").write_text(json.dumps({"sha1": "MISSING", "password": "secret"})) + with self.assertRaisesRegex(RuntimeError, "certificate is missing; run .*remove --directory .*, then .*setup --directory "): + signing.setup(self.directory) + self.assertTrue(self.keychain.is_file()) + + def test_invalid_identity_record_reports_recovery_without_its_contents(self): + self.configure() + (self.directory / "identity.json").write_text("secret invalid json") + with self.assertRaisesRegex(RuntimeError, "record is invalid; run .*remove") as error: + signing.setup(self.directory) + self.assertNotIn("secret", str(error.exception)) + + def test_private_command_failures_never_echo_arguments_or_output(self): + for tool, action in ((signing.SECURITY, "unlock-keychain"), (signing.OPENSSL, "pkcs12")): + command = [tool, action, "-p", "secret-password"] + for failure in (subprocess.TimeoutExpired(command, 60, stderr="secret-password"), + subprocess.CalledProcessError(1, command, stderr="secret-password"), + OSError(2, "secret-password")): + with self.subTest(tool=tool, failure=type(failure).__name__), \ + patch.object(signing.subprocess, "run", side_effect=failure): + with self.assertRaises(RuntimeError) as error: + signing.private_command(command) + self.assertNotIn("secret-password", str(error.exception)) + self.assertTrue(error.exception.__suppress_context__) + + def test_setup_and_remove_hold_the_same_cross_process_lock(self): + self.configure() + probe = """import fcntl, os, sys +with open(sys.argv[1], 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(0) + sys.exit(1) +""" + setup_locked, remove_locked = signing.setup_locked, signing.remove_locked + def check_lock(operation): + def run(directory): + result = subprocess.run([sys.executable, "-c", probe, str(directory.parent / f"{directory.name}.lock")], + capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + return operation(directory) + return run + with patch.object(signing, "setup_locked", side_effect=check_lock(setup_locked)), \ + patch.object(signing, "remove_locked", side_effect=check_lock(remove_locked)): + signing.setup(self.directory) + signing.remove(self.directory) + with signing.identity_lock(self.directory): + pass + + def test_setup_cli_reports_signing_timeout_without_traceback_or_password(self): + script = """import runpy, sys, subprocess +from unittest.mock import patch +from pathlib import Path +test_home = Path(sys.argv[2]) +Path.home = lambda: test_home +sys.argv = [sys.argv[1], 'setup', '--directory', sys.argv[2]] +with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['security', 'unlock-keychain', '-p', 'secret-password'], 60)): + runpy.run_path(sys.argv[0], run_name='__main__') +""" + # Fail at the first security command, before setup can modify any keychain. + result = subprocess.run([sys.executable, "-c", script, str(Path(signing.__file__)), str(self.root / "home")], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertIn("timed out", result.stderr) + self.assertNotIn("secret-password", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/dsh-official-plugin-artwork/SKILL.md b/.agents/skills/dsh-official-plugin-artwork/SKILL.md index 809409c8b5..fce1964d39 100644 --- a/.agents/skills/dsh-official-plugin-artwork/SKILL.md +++ b/.agents/skills/dsh-official-plugin-artwork/SKILL.md @@ -7,6 +7,8 @@ description: Draw and review official DeepSeek Harness plugin icons and optional Draw recognizable artwork that belongs beside the existing official icons. Official and official experimental plugins share this visual language; the UI label communicates experimental status. This guidance does not replace designer approval. +When reviewing the containing page, use the [UI review baseline](../../../docs/ui-design-review-cases.md#review-baseline) to combine artwork balance with its tile, label, neighboring cards and theme material. Keep package artwork distinct from action icons: coherent optical weight does not require equal drawing bounds, monochrome fills or identical geometry across those roles. + ## Optional feature previews Before drawing a larger image for the top of an official bundle's detail document, read and follow [adding a plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, position, language variants, file location, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature and preserve its produced assets. @@ -14,7 +16,7 @@ Before drawing a larger image for the top of an official bundle's detail documen ## Establish the subject - Inspect the actual feature, its output, and its owning manifest before drawing. A badge-producing skill may need a tag, not a medal. Reuse designer-supplied assets; do not invent a brand mark or change product names, identifiers, or unrelated icons without approval. -- Read the [existing artwork](../../../packages/client/ui-primitives/src/plugin-artwork.tsx) and the nearest relevant package SVGs. Borrow one coherent treatment, not every reference's effects. Action icons remain with [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md#reuse-before-adding). +- Read the [existing artwork](../../../packages/client/ui-primitives/src/plugin-artwork.tsx) and the nearest relevant package SVGs. Borrow one coherent treatment, not every reference's effects. Action icons remain with [the UI design entry](../dsh-client-ui-ux/SKILL.md#select-the-rule-owners). - Check every affected entry, list, detail, and component row. Bundle artwork and a component's name and icon can have separate metadata owners; changing one does not update the other. ## Draw and refine diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 3d55a9dece..e10d03cecc 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -33,6 +33,7 @@ When the outgoing change adds or changes a resource-owning or asynchronous test, - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. - **Remote mock typing:** unbuilt `any` is an explicit local fallback, not strict evidence. Run `pnpm run typecheck` before handing off Remote/mock changes; rebuild missing, stale, or partial generated declarations before diagnosing remaining errors. Keep the exception in the [test proxy](../../../packages/test-support/remote-mock/README.md#remote-proxy), never in production Remote types, ambient flags, or copied signatures. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. +- **User-visible UI:** use the [UI change workflow](../dsh-client-ui-ux/SKILL.md#required-review-loop) and its [minimum checks](../dsh-client-ui-ux/SKILL.md#minimum-checks-for-affected-ui) to verify required inspection and new cumulative effects. Disclose missing checks; optional paperwork is not a substitute for inspecting the result. Reuse valid evidence; ordinary changes do not need a separate full-audit report at push time. Keep existing test, GUI GIF and designer/product approval requirements. Tests alone do not establish visual acceptance. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. - **Profile and configuration placement:** cross-package behavior of a shipped `dsh` profile belongs under `apps/cli/tests/profiles/`; a package-specific Loader composition belongs under that package's `tests/fixtures/`. User-facing optional overlays live under `apps/cli/config/examples/` and pair with a guide under `docs/user/`. diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md index 73e0a8f944..44b2fbe977 100644 --- a/.agents/skills/dsh-responsive-ui/SKILL.md +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -5,6 +5,8 @@ description: Use when designing, implementing, or reviewing responsive client la # Responsive client UI +For visual reviews, select the [impact scope](../../../docs/cookbook/reviewing-ui-design.md#review-scope), then apply these behavioral requirements and the [UI review baseline](../../../docs/ui-design-review-cases.md#review-baseline). Compare affected wrapping and layout relationships in the whole page or modal's context while preserving drafts, focus and action access. Inspect all regions for an explicit full-interface review. Examples do not define coverage; fitting the viewport alone is insufficient. + ## 1. Minimum viewport Use a viewport width of 390 CSS px as the primary acceptance minimum. There must be no unintended overlap, clipping, page-wide horizontal scrolling, or inaccessible primary actions. Below 390px, layout requirements may be relaxed. diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index a16f8294e9..1fa60fcb4f 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -1,6 +1,6 @@ --- name: record-browser-gif -description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. +description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`; publication requires gh v2.99.0 or later, and a GIF over 5 MiB is split into parts. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. --- # Record Browser GIF @@ -17,8 +17,8 @@ The recording itself is part of the evidence: use a real server booted from that ## Keep recording separate from publication -- Recording produces local video or screenshots and one `.gif` artifact only; it never mutates remote state. -- Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. +- Recording produces local video or screenshots; encoding produces one `.gif` artifact, or its [numbered parts](#attach-with-gh) when it exceeds the 5 MiB limit. Neither mutates remote state. +- Publication — attaching the GIF to a pull request body with `gh --attach` — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never commits to any branch. - Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture. - Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt. @@ -117,13 +117,15 @@ Encoder maintenance: run `python3 -m unittest discover -s "$GIF_SKILL_DIR/script Perform this step only when the task includes attaching the GIF to a pull request. -Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. Prefer `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media. +Never commit a GIF to the pull request's own branch or any other branch: every default clone and full-history fetch of the repository downloads media committed to any branch. Publish only with `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media. ### Attach with gh -`gh --attach` requires `gh` v2.99.0 or later (`gh --version`), a repository on github.com — GitHub Enterprise Server is not supported — write access to the repository, and a GIF at or below 10 MB. Confirm the verified artifact fits that limit; when it does not, shrink it with `--max-width`, then `--colors` or `--fps`, before attaching. +Before attaching, check `gh --version`. `gh --attach` requires `gh` v2.99.0 or later, a repository on github.com — GitHub Enterprise Server is not supported — and write access to the repository. When `gh` is older than v2.99.0, stop and tell the developer to upgrade `gh` to v2.99.0 or later; keep the verified GIF for the retry, and do not install or upgrade `gh` without authorization. When any other requirement is unmet or the attach fails — authentication, write access, a repository outside github.com, or an upload error — stop and report it to the developer. Never publish the GIF any other way. -Write the GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites the reference in place to the uploaded URL, keeping its position and alt text: +Publish GIFs at or below 5 MiB, the encoder's default `--max-bytes`. When the encoder rejects the output for size, shrink it with `--max-width`, then `--colors` or `--fps`. When the GIF still exceeds 5 MiB at readable text quality, split it instead: encode consecutive, non-overlapping intervals of the same evidence run into numbered parts that each pass the encoder's size check — `--start` and `--end` over the one video, or consecutive screenshot subsets selected with `--pattern` — and verify every part as an artifact. Beside each embed, state its position (such as `2/3`) and its source interval or frames. + +Write each GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites each reference in place to its uploaded URL, keeping its position and alt text, so the order of references in the body sets the display order of split parts: ```markdown ![]() @@ -138,35 +140,8 @@ gh pr create --body-file --attach # new pull re gh pr edit --body-file --attach # existing pull request ``` -`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '#'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected ``, and fetch the uploaded URL once to confirm `200` and `image/gif`. +`--attach` is repeatable but refuses the same file twice. Publish split parts in one `gh pr create` or `gh pr edit` with one `--attach` per part, so a single pair of live-head checks covers every part. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '#'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected ``, and fetch the uploaded URL once to confirm `200` and `image/gif`. -### Fall back to an assets branch +### Existing assets branches -Use the assets-branch workflow only when `gh --attach` cannot apply: the GIF still exceeds 10 MB, `gh` is older than v2.99.0, or the repository is not on github.com. GIFs then live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`). - -Before either workflow below pushes, verify that the assets branch contains media only and that the staged GIF's checksum matches the verified local artifact. - -For an existing assets branch, work in a shallow single-branch scratch clone so the publication cannot touch your working tree: - -```sh -git clone --branch --single-branch --depth 1 /tmp/assets-checkout -cp /absolute/path/to/demo.gif /tmp/assets-checkout/.gif -cd /tmp/assets-checkout -git add .gif -git commit -m "assets: gif (#)" -git push origin -``` - -For a new series, make a fresh shallow scratch clone (`git clone --depth 1 /tmp/assets-checkout`), create the orphan branch with `git switch --orphan `, then add the GIF, commit, and push the same way. - -After pushing, use authenticated GitHub API or raw requests to confirm the remote path, byte size, checksum, `200` response, and `image/gif` content type. An anonymous `404` does not disprove a private-repository asset; authenticate the verification instead. This proves the repository-member review path, not public availability. - -Immediately before editing the pull-request body, re-read the demonstrated pull request's live head and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. After the edit, re-read the demonstrated live head and require it to remain at that recorded commit. Separately, render the body through GitHub's Markdown API and confirm that the expected `` is present. - -Embed the GIF in the pull request body with the raw blob URL; the `?raw=true` suffix is required, because the plain blob URL renders GitHub's file page instead of the image: - -```markdown -![](https://github.com///blob//.gif?raw=true) -``` - -Never delete or rewrite an assets branch, and never force-push it: merged pull request bodies reference its URLs forever. Append new commits only. +Earlier pull request bodies embed GIFs from orphan `*-assets` branches in this repository. Never add to, delete, rewrite, or force-push them: merged pull request bodies reference their URLs. diff --git a/.github/AGENTS.md b/.github/AGENTS.md index bde3142e27..c69f322610 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. +The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. -The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. +The manual Desktop publication workflow selects the original successful `package` job attempt independently of `upload-artifact` archival status, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 6bcdbee5ac..35f5a33df1 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -337,7 +337,8 @@ jobs: smoke_python="$venv/bin/python" "$smoke_python" -m pip install \ "dist-python/$SDK_WHEEL" \ - "dist-python/$RUNTIME_WHEEL" + "dist-python/$RUNTIME_WHEEL" \ + 'pytest>=8.0' echo "python=$smoke_python" >> "$GITHUB_OUTPUT" - name: Install local SDK and runtime wheels into a clean venv (Windows) @@ -351,7 +352,7 @@ jobs: $venv = (& python -c 'import tempfile; print(tempfile.mkdtemp(prefix="dsh-sdk-smoke-"))').Trim() python -m venv $venv $smokePython = Join-Path $venv 'Scripts\python.exe' - & $smokePython -m pip install "dist-python/$env:SDK_WHEEL" "dist-python/$env:RUNTIME_WHEEL" + & $smokePython -m pip install "dist-python/$env:SDK_WHEEL" "dist-python/$env:RUNTIME_WHEEL" 'pytest>=8.0' if ($LASTEXITCODE -ne 0) { throw "Wheel installation failed with exit code $LASTEXITCODE" } "python=$smokePython" >> $env:GITHUB_OUTPUT @@ -382,6 +383,19 @@ jobs: Pop-Location } + - name: Run installed-wheel native command tests (POSIX) + if: runner.os != 'Windows' + run: >- + "${{ steps.smoke-venv-posix.outputs.python }}" -I + "$GITHUB_WORKSPACE/python/sdk/tests/run_installed_commands.py" + + - name: Run installed-wheel native command tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + & "${{ steps.smoke-venv-windows.outputs.python }}" -I "$env:GITHUB_WORKSPACE\python\sdk\tests\run_installed_commands.py" + if ($LASTEXITCODE -ne 0) { throw "Installed-wheel native command tests failed with exit code $LASTEXITCODE" } + - name: Preflight installed-wheel real API test (POSIX) if: >- inputs.ci diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index a4ed098609..47ba28681e 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -73,6 +73,12 @@ jobs: - name: Run blocking Windows builds shell: pwsh run: pnpm run check:ci:windows-blocking + - name: Verify Desktop updater dependency versions + shell: pwsh + run: | + node --test apps/desktop/tests/updates/electron-updater/versions.test.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node apps/desktop/tests/updates/electron-updater/check-versions.mjs - name: Parse Windows runner supervision scripts shell: pwsh run: | diff --git a/.github/workflows/cua-helper-package.yml b/.github/workflows/cua-helper-package.yml new file mode 100644 index 0000000000..650138cd37 --- /dev/null +++ b/.github/workflows/cua-helper-package.yml @@ -0,0 +1,109 @@ +name: Package CUA helper · macOS + +on: + workflow_dispatch: + inputs: + preset: + description: '机器上的签名配置' + type: choice + options: [test, production] + default: test + required: true + workflow_call: + inputs: + reuse: + type: boolean + default: false + cua_run_id: + type: string + default: '' + preset: + type: string + default: production + outputs: + artifact-id: + description: '包含两个架构的已签名、公证 helper 的产物编号' + value: ${{ jobs.package.outputs.artifact-id }} + run-id: + description: '产物所属的构建运行编号' + value: ${{ jobs.package.outputs.run-id }} + build-key: + description: '源码和构建配置的摘要' + value: ${{ jobs.package.outputs.build-key }} + +permissions: + contents: read + actions: read + +# Desktop uses the same keychain search list and optional system proxy transaction. +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 60 + outputs: + artifact-id: ${{ steps.retained.outputs.artifact-id || steps.upload.outputs.artifact-id }} + run-id: ${{ steps.retained.outputs.run-id || github.run_id }} + build-key: ${{ steps.build-inputs.outputs.build-key }} + env: + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: true + - name: Validate run ref and preset + run: | + node -e 'const fs=require("node:fs"); const ref=process.env.GITHUB_REF; + if(!ref || (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/"))) throw Error("Select a trusted repository branch or release tag"); + const tag=ref.replace(/^refs\/tags\//, ""); + if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version"); + if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");' + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - name: Record CUA helper build inputs + id: build-inputs + run: node scripts/cua-helper/build-inputs.ts + - name: Find reusable CUA helper + if: inputs.reuse + id: retained + env: + GH_TOKEN: ${{ github.token }} + CUA_RUN_ID: ${{ inputs.cua_run_id }} + CUA_BUILD_KEY: ${{ steps.build-inputs.outputs.build-key }} + run: node scripts/cua-helper/select-artifact.ts + - uses: pnpm/action-setup@v5.0.0 + if: steps.retained.outputs.artifact-id == '' + with: + dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }} + - name: Install locked dependencies + if: steps.retained.outputs.artifact-id == '' + run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile + - name: Build and verify notarized applications + if: steps.retained.outputs.artifact-id == '' + env: + CUA_EXPECTED_BUILD_KEY: ${{ steps.build-inputs.outputs.build-key }} + run: | + mkdir -p dist + node --import tsx/esm scripts/cua-helper/package.ts --out dist/cua-helper + - uses: actions/upload-artifact@v7.0.1 + if: steps.retained.outputs.artifact-id == '' + id: upload + with: + name: cua-helper-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.build-inputs.outputs.build-key }} + path: dist/cua-helper/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 + - name: Record newly built artifact + if: steps.retained.outputs.artifact-id == '' + env: + CUA_ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + run: | + printf '已构建 CUA helper,run ID `%s`,artifact ID `%s`。\n' "$GITHUB_RUN_ID" "$CUA_ARTIFACT_ID" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 3e5c006d7b..ff756d1b95 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -9,6 +9,11 @@ on: description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包' type: string required: true + upload_website: + description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布' + type: boolean + default: true + required: true package_run_attempt: description: '打包尝试次数:留空选择最新一次,且必须成功;失败时不会回退到旧包' type: string @@ -57,9 +62,13 @@ jobs: route: ${{ steps.local.outputs.route }} preset: ${{ steps.local.outputs.preset }} approval: ${{ steps.check.outputs.approval }} + website-approval: ${{ steps.check.outputs.website-approval }} + website-destination: ${{ steps.check.outputs.website-destination }} label: ${{ steps.check.outputs.label }} env: DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} DSH_TELEMETRY_DISABLED: '1' steps: - uses: actions/checkout@v7.0.1 @@ -151,6 +160,7 @@ jobs: DESKTOP_TAG_TOKEN: ${{ steps.app.outputs.token }} run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --publish - name: Retain publication audit + continue-on-error: true if: always() && steps.publish.outputs.record != '' uses: actions/upload-artifact@v7.0.1 with: @@ -158,3 +168,93 @@ jobs: path: ${{ steps.publish.outputs.record }}/ if-no-files-found: error retention-days: 30 + + upload-website-installer: + needs: [route, prepare, publish] + if: inputs.upload_website && needs.prepare.outputs.preset != 'test' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-upload-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Upload website installer + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-website-ci.ts upload + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-upload-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 + + purge-website-installer: + needs: [route, prepare, upload-website-installer] + if: inputs.upload_website && needs.prepare.outputs.preset != 'test' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-purge-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Recover and observe website cache purge + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-website-ci.ts purge + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-purge-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index cc835b8933..3fdb193807 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: @@ -57,6 +57,13 @@ concurrency: jobs: package: + outputs: + directory: ${{ steps.retained.outputs.directory }} + runner: ${{ steps.retained.outputs.runner }} + runner-name: ${{ steps.retained.outputs.runner-name }} + attempt: ${{ steps.retained.outputs.attempt }} + version: ${{ steps.settings.outputs.version }} + mode: ${{ steps.settings.outputs.mode }} runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] timeout-minutes: 180 defaults: @@ -98,6 +105,7 @@ jobs: if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source'); + if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -115,6 +123,10 @@ jobs: run: arch -x86_64 /usr/bin/true - name: Install locked dependencies run: pnpm install --frozen-lockfile + - name: Check original machine archival route + env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner - name: Validate preset and resolve build version id: settings env: @@ -127,6 +139,23 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build and stage notarized Computer Use helpers + # A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime, + # which therefore needs the same notarized helper npm publication stages. + run: | + if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then + echo 'This source does not ship Computer Use in Desktop; no helper is staged.' + exit 0 + fi + if [[ ! -f scripts/cua-helper/package.ts ]]; then + echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.' + exit 0 + fi + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} @@ -137,10 +166,12 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics + continue-on-error: true if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 with: @@ -149,13 +180,46 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 + + upload-artifact: + needs: package + runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] + timeout-minutes: 30 + env: + DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} + DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + path: artifact-archiver + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: artifact-archiver/package.json + dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" + - run: pnpm install --frozen-lockfile + working-directory: artifact-archiver + - name: Verify original retained build before GitHub archival + working-directory: artifact-archiver + run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* compression-level: 0 if-no-files-found: error retention-days: 14 + overwrite: true diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 073f1e9541..f6f9f97401 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: @@ -57,6 +57,13 @@ concurrency: jobs: package: + outputs: + directory: ${{ steps.retained.outputs.directory }} + runner: ${{ steps.retained.outputs.runner }} + runner-name: ${{ steps.retained.outputs.runner-name }} + attempt: ${{ steps.retained.outputs.attempt }} + version: ${{ steps.settings.outputs.version }} + mode: ${{ steps.settings.outputs.mode }} runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] timeout-minutes: 180 defaults: @@ -97,6 +104,7 @@ jobs: const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } + if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -112,6 +120,10 @@ jobs: echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - name: Install locked dependencies run: pnpm install --frozen-lockfile + - name: Check original machine archival route + env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner - name: Validate preset and resolve build version id: settings env: @@ -124,6 +136,23 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build and stage notarized Computer Use helpers + # A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime, + # which therefore needs the same notarized helper npm publication stages. + run: | + if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then + echo 'This source does not ship Computer Use in Desktop; no helper is staged.' + exit 0 + fi + if [[ ! -f scripts/cua-helper/package.ts ]]; then + echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.' + exit 0 + fi + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} @@ -134,10 +163,12 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics + continue-on-error: true if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 with: @@ -146,13 +177,46 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 + + upload-artifact: + needs: package + runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] + timeout-minutes: 30 + env: + DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} + DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + path: artifact-archiver + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: artifact-archiver/package.json + dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" + - run: pnpm install --frozen-lockfile + working-directory: artifact-archiver + - name: Verify original retained build before GitHub archival + working-directory: artifact-archiver + run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* compression-level: 0 if-no-files-found: error retention-days: 14 + overwrite: true diff --git a/.github/workflows/pr-check-after-approve.yml b/.github/workflows/pr-check-after-approve.yml index 1255ae9373..85e84e5c89 100644 --- a/.github/workflows/pr-check-after-approve.yml +++ b/.github/workflows/pr-check-after-approve.yml @@ -1,7 +1,7 @@ name: Policy on: pull_request_target: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted, dismissed] @@ -14,47 +14,15 @@ concurrency: jobs: approve: name: Approve Check - # Each check runs only while its repository variable is neither empty nor `off`; otherwise the job is skipped. + # Skipped while MERGE_POLICY_HEAD_DIFF is empty or `off`; otherwise the job fails by that mode and publishes + # `Policy / Reviewer Check` whatever that mode says. if: ${{ vars.MERGE_POLICY_HEAD_DIFF != '' && vars.MERGE_POLICY_HEAD_DIFF != 'off' }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: read - steps: - # The head checkout is only the Git repository the check fetches into; it needs no files and runs no code. - - uses: actions/checkout@v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 1 - filter: blob:none - sparse-checkout: README.md - sparse-checkout-cone-mode: false - - - uses: actions/setup-node@v7.0.0 - with: - node-version: '24' - package-manager-cache: false - - - uses: deepseek-harness/deepseek-harness-check@master - env: - CHECK_MODE: ${{ vars.MERGE_POLICY_HEAD_DIFF }} - with: - check: head-diff - base-branch: ${{ github.event.pull_request.base.ref }} - head-sha: ${{ github.event.pull_request.head.sha }} - pr-number: ${{ github.event.pull_request.number }} - pr-author: ${{ github.event.pull_request.user.login }} - - review: - name: Reviewer Evaluate - if: ${{ vars.MERGE_POLICY_REVIEW != '' && vars.MERGE_POLICY_REVIEW != 'off' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - pull-requests: read - # The check posts its verdict as a check run of its own. + # The check posts the review verdict as a check run of its own. checks: write steps: # The head checkout is only the Git repository the check fetches into; it needs no files and runs no code. @@ -84,7 +52,7 @@ jobs: - uses: deepseek-harness/deepseek-harness-check@master env: - CHECK_MODE: ${{ vars.MERGE_POLICY_REVIEW }} + CHECK_MODE: ${{ vars.MERGE_POLICY_HEAD_DIFF }} CHECK_TOKEN: ${{ steps.token.outputs.token }} with: check: review diff --git a/.github/workflows/pr-check-on-base-branch.yml b/.github/workflows/pr-check-on-base-branch.yml index 32d4d46165..b4a9a474e6 100644 --- a/.github/workflows/pr-check-on-base-branch.yml +++ b/.github/workflows/pr-check-on-base-branch.yml @@ -1,7 +1,7 @@ name: Policy on: push: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] workflow_dispatch: permissions: {} concurrency: diff --git a/.github/workflows/pr-check-on-head-branch.yml b/.github/workflows/pr-check-on-head-branch.yml index adb884d62c..a926d97827 100644 --- a/.github/workflows/pr-check-on-head-branch.yml +++ b/.github/workflows/pr-check-on-head-branch.yml @@ -1,7 +1,7 @@ name: Policy on: pull_request_target: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] types: [opened, synchronize, reopened, ready_for_review, review_requested, auto_merge_enabled, labeled] permissions: contents: read diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index c30d75af0d..27a5a52916 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -8,6 +8,11 @@ name: Release publish (dsh) on: workflow_dispatch: inputs: + cua_run_id: + description: 'CUA helper run ID,留空复用源码逐字节一致的产物,无匹配则构建' + type: string + required: false + default: '' skip-tag-check: description: Skip release tag validation for the selected ref; other publication checks still apply. type: boolean @@ -16,15 +21,28 @@ on: permissions: contents: read + actions: read env: PRIMARY_NODE_VERSION: '24' DSH_TELEMETRY_DISABLED: '1' jobs: + cua-helper: + if: startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check + uses: ./.github/workflows/cua-helper-package.yml + with: + reuse: true + preset: production + cua_run_id: ${{ inputs.cua_run_id }} + pack: name: Pack npm tarballs + needs: cua-helper runs-on: ubuntu-24.04 + permissions: + contents: read + actions: read steps: # Complete history: the release scripts read tags. - uses: actions/checkout@v7.0.1 @@ -63,9 +81,36 @@ jobs: RELEASE_PUBLISH: 'true' run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }} + - name: Download selected CUA helper + uses: actions/download-artifact@v8.0.1 + with: + artifact-ids: ${{ needs.cua-helper.outputs.artifact-id }} + run-id: ${{ needs.cua-helper.outputs.run-id }} + github-token: ${{ github.token }} + path: dist/cua-helper + - name: Build run: pnpm run build:official --serial + - name: Stage verified native helper applications + env: + CUA_EXPECTED_BUILD_KEY: ${{ needs.cua-helper.outputs.build-key }} + run: | + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted + + - name: Record CUA source and publication retry instructions + env: + CUA_RUN_ID: ${{ needs.cua-helper.outputs.run-id }} + CUA_ARTIFACT_ID: ${{ needs.cua-helper.outputs.artifact-id }} + run: | + { + printf '采用 CUA helper 构建 run ID `%s`,artifact ID `%s`。\n\n' "$CUA_RUN_ID" "$CUA_ARTIFACT_ID" + printf '%s\n\n' 'npm 部分发布失败时,只重跑 Publish to npm job,复用原 dsh-npm-tarballs;不要重跑全部 job。' + printf '需要重新打包时,可留空自动查找,或填写 `cua_run_id=%s` 指定该 helper;源码和构建配置必须匹配。\n' "$CUA_RUN_ID" + } >> "$GITHUB_STEP_SUMMARY" + - name: Pack release tarballs run: pnpm run release:pack --family dsh --out dist/npm diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index cbb4fd2642..0b137fc3b2 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -15,9 +15,9 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' type: choice - options: [nightly, latest] + options: [nightly, stable] default: nightly required: true version_mode: @@ -53,11 +53,6 @@ on: type: string default: '' required: false - build_root: - description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区' - type: string - default: '' - required: false permissions: contents: read @@ -68,6 +63,13 @@ concurrency: jobs: package: + outputs: + directory: ${{ steps.retained.outputs.directory }} + runner: ${{ steps.retained.outputs.runner }} + runner-name: ${{ steps.retained.outputs.runner-name }} + attempt: ${{ steps.retained.outputs.attempt }} + version: ${{ steps.settings.outputs.version }} + mode: ${{ steps.settings.outputs.mode }} runs-on: dsh-win-package-trial timeout-minutes: 120 defaults: @@ -82,7 +84,6 @@ jobs: PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} PACKAGE_SIGNING: ${{ inputs.signing }} PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} - PACKAGE_BUILD_ROOT: ${{ inputs.build_root }} PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }} steps: - name: Reset packaging checkout @@ -106,7 +107,7 @@ jobs: run: | $helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null - foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) { + foreach ($name in @('windows-packaging-priority.ps1')) { Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop } "PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV @@ -133,6 +134,7 @@ jobs: const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } + if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -140,17 +142,6 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' - - name: Select packaging workspace - id: build_workspace - env: - PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} - run: | - if (!$env:PACKAGE_BUILD_ROOT) { - "directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT - } else { - node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" - exit $LASTEXITCODE - } - name: Configure persistent pnpm store # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. env: @@ -167,7 +158,6 @@ jobs: "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install locked dependencies - working-directory: ${{ steps.build_workspace.outputs.directory }} run: | & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))" @@ -176,14 +166,16 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm install --frozen-lockfile exit $LASTEXITCODE + - name: Check original machine archival route + env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner - name: Validate preset and resolve build version id: settings - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | @@ -192,58 +184,95 @@ jobs: pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime - working-directory: ${{ steps.build_workspace.outputs.directory }} env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} - PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }} run: | & "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort - if ($env:PACKAGE_BUILD_TEMP) { - $env:TEMP = $env:PACKAGE_BUILD_TEMP - $env:TMP = $env:PACKAGE_BUILD_TEMP - } $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } pnpm @packageArgs exit $LASTEXITCODE - name: Retain complete package outside the checkout id: retained - working-directory: ${{ steps.build_workspace.outputs.directory }} env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics + continue-on-error: true if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 with: name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true - path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ + path: ${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 + + upload-artifact: + defaults: + run: + shell: pwsh + needs: package + runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] + timeout-minutes: 30 + env: + PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} + DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} + DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + path: artifact-archiver + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: artifact-archiver/package.json + dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Configure persistent pnpm store + # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' + $namespace = $env:PACKAGE_CACHE_NAMESPACE + if ($namespace) { + if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' } + if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' } + $storeRoot = Join-Path $storeRoot $namespace + $env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace + } + "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV + "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV + - run: pnpm install --frozen-lockfile + working-directory: artifact-archiver + - name: Verify original retained build before GitHub archival + working-directory: artifact-archiver + run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain verified unsigned installer if: success() && inputs.signing == 'unsigned' uses: actions/upload-artifact@v7.0.1 with: - name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: windows-x64-unsigned-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} compression-level: 0 include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* if-no-files-found: error retention-days: 14 + overwrite: true - name: Retain verified signed installer if: success() && inputs.signing == 'signed' uses: actions/upload-artifact@v7.0.1 with: - name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: windows-x64-signed-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} compression-level: 0 include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* if-no-files-found: error retention-days: 14 - - name: Remove isolated packaging workspace - if: success() && inputs.build_root != '' - env: - PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} - run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove + overwrite: true diff --git a/.gitignore b/.gitignore index 3cb86ce87d..51b7cf53dd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ apps/web/dist/ .artifacts/ .dsh-build/ .dsh-report/ +.ui-review/ .playwright-mcp/ .orig .worktrees/ @@ -47,3 +48,6 @@ worktrees/ .agents/worktrees/ .typert-*/ native/system/test/bin/ + +# Generated company-signed CUA release record. +/packages/experimental/cua-macos/cua-helper.json diff --git a/AGENTS.md b/AGENTS.md index 69dbd7d69e..278a2be722 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// compaction/ context compaction context/ request context subagent/ delegated agents + team/ shared task coordination jobs/ background jobs bundle/ profile bundles workflow/ workflow execution @@ -156,6 +157,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). +- **UI changes:** use [dsh-client-ui-ux](.agents/skills/dsh-client-ui-ux/SKILL.md) as the design and review entry point; load its references only as needed. - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a1a13b2200..c38e1a3cea 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -91,6 +91,8 @@ External packages installed for runtime use or embedded in distributed Host or b | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | +| [`eslint-scope`](https://github.com/eslint/js) | BSD-2-Clause | +| [`eslint-visitor-keys`](https://github.com/eslint/js) | Apache-2.0 | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`exceljs`](https://github.com/exceljs/exceljs) | MIT | | [`execa`](https://github.com/sindresorhus/execa) | MIT | @@ -131,6 +133,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`react-dom`](https://github.com/facebook/react) | MIT | | [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | | [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | +| [`rrule`](https://github.com/jakubroztocil/rrule) | BSD-3-Clause | | [`semver`](https://github.com/npm/node-semver) | ISC | | [`serve-static`](https://github.com/expressjs/serve-static) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | @@ -219,6 +222,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/big.js`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/estree`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/hosted-git-info`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 21150774cc..96b711a333 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -140,6 +140,10 @@ flowchart LR cfg --> plugin_dsh_base_command_compact plugin_dsh_base_subagent["subagent
@deepseek-ai/dsh-subagent"] cfg --> plugin_dsh_base_subagent + plugin_dsh_base_agent_team["agent-team
@deepseek-ai/dsh-agent-team"] + cfg --> plugin_dsh_base_agent_team + plugin_dsh_base_tool_agent_team["tool-agent-team
@deepseek-ai/dsh-tool-agent-team"] + cfg --> plugin_dsh_base_tool_agent_team plugin_dsh_base_subagent_spawn_in_process["subagent-spawn-in-process
@deepseek-ai/dsh-subagent-spawn-in-process"] cfg --> plugin_dsh_base_subagent_spawn_in_process plugin_dsh_base_subagent_fork_in_process["subagent-fork-in-process
@deepseek-ai/dsh-subagent-fork-in-process"] @@ -272,6 +276,8 @@ flowchart LR | `compaction-basic` | `@deepseek-ai/dsh-compaction-basic` | | `command-compact` | `@deepseek-ai/dsh-command-compact` | | `subagent` | `@deepseek-ai/dsh-subagent` | +| `agent-team` | `@deepseek-ai/dsh-agent-team` | +| `tool-agent-team` | `@deepseek-ai/dsh-tool-agent-team` | | `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` | | `subagent-fork-in-process` | `@deepseek-ai/dsh-subagent-fork-in-process` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | diff --git a/apps/cli/config/examples/ptc.yml b/apps/cli/config/examples/ptc.yml index d0c2cc888a..65905eacdd 100644 --- a/apps/cli/config/examples/ptc.yml +++ b/apps/cli/config/examples/ptc.yml @@ -3,3 +3,8 @@ - id: tool-executor-ptc disabled: false + +# PTC mode exposes the pre-apply_patch tool set, as the Web ptc preset does. +- id: tool-fs + config: + applyPatch: false diff --git a/apps/cli/config/examples/team-mode.yml b/apps/cli/config/examples/team-mode.yml new file mode 100644 index 0000000000..5204952bd1 --- /dev/null +++ b/apps/cli/config/examples/team-mode.yml @@ -0,0 +1,3 @@ +- id: agent-team + config: + defaultEnabled: true diff --git a/apps/cli/package.json b/apps/cli/package.json index a77b89b9f2..5bddabe27e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "publishConfig": { "access": "public" }, @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", "@deepseek-ai/dsh-experimental-inspector-profile": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", @@ -128,9 +127,9 @@ "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:*", - "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:*", + "@deepseek-ai/dsh-tool-agent-team": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:*", "@deepseek-ai/dsh-host-frontend-static": "workspace:*", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ffd79b8daa..2c33416553 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -3,7 +3,7 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md /dsh-cli-behavior-reference: - en: 5a4f43756dae900e + en: abffba1bcc44698c zh: d58c479481cfa5fe /dsh-cli-behavior-reference/profile-boot: en: b474a6fc3c98837d @@ -24,8 +24,8 @@ en: 149c5571acd51baf zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: - en: f40edcf903a51f3f - zh: 8a9a915451047c8b + en: f02219118dcec531 + zh: fe2977628d7340da /dsh-cli-behavior-reference/source-execution: - en: 05c2b23790f7b296 - zh: 51f809020fb230a1 + en: 0fd6c72ba9a7eb3b + zh: 011c0ac4008ee480 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index a375450048..dc84a267d8 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -6,6 +6,8 @@ This reference defines the profile, plugin-management, and config-dump command m The [generated command help](../../../docs/cli-help.md) contains complete launcher, plugin-management, and shipped-profile help. Run `pnpm run gen-cli-help` to refresh it; `pnpm run verify-cli-help` checks it against the current CLI. + + ## Profile boot `dsh ` abbreviates `dsh --profile ` and boots the profile at `$DSH_HOME/profiles/`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. @@ -134,7 +136,7 @@ Web, headless, and SDK inherit native tool execution from the base bundle; use a ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. The DeepSeek Search and Fetch APIs follow the session's DeepSeek credentials; custom endpoints and keys can only be set in YAML through `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` and `fetchBaseURL` / `fetchApiKeyEnv` ([authentication](../../../packages/web/web-deepseek-api/README.md#authentication)). Legacy Messages search reads `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths. @@ -143,4 +145,4 @@ Install external plugin bundles through `dsh plugin --profile add ## Source execution -From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository. +From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. On Windows, the installed command expands 8.3 paths before loading profile dependencies. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 642fa18bc5..2de7755983 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -136,7 +136,7 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据;自定义 endpoint 和密钥只能在 YAML 中通过 `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` 和 `fetchBaseURL` / `fetchApiKeyEnv` 设置([鉴权](../../../packages/web/web-deepseek-api/README.zh.md#authentication))。旧版 Messages 搜索读取 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。 @@ -145,4 +145,4 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov ## 源码执行 -请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。在 Windows 上,安装版命令在加载 profile 依赖前展开 8.3 短路径。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/src/installed-bin.ts b/apps/cli/src/installed-bin.ts new file mode 100644 index 0000000000..a9e9b2ae7b --- /dev/null +++ b/apps/cli/src/installed-bin.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/** + * Installed CLI entry for disk launchers and embedded runtimes. + * @module @deepseek-ai/dsh/installed-bin + */ +import { realpathSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import type { RunCliOptions } from './bin.ts' + +/** + * Run the CLI in the importing carrier's module tree. + * @param options - Installation-owned runtime options. + * @returns Settlement of the selected command. + */ +export async function runCli(options?: RunCliOptions): Promise { + const cli = await import('./bin.ts') + await cli.runCli(options) +} + +if (import.meta.main) { + // Windows ESM can retain an 8.3 spelling while profile resolution expands it. + const entry = process.platform === 'win32' ? pathToFileURL(realpathSync.native(import.meta.filename)).href : import.meta.url + if (entry === import.meta.url) await runCli() + else { + const canonical = await import(entry) as typeof import('./installed-bin.ts') + await canonical.runCli() + } +} diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts index dd4e456252..e38a33777d 100644 --- a/apps/cli/tests/agent-team-headless.e2e.ts +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -17,7 +17,7 @@ function records(content: string): Record[] { } describe('dsh run with Agent Teams enabled', () => { - it('runs two teammates, direct peer messages, dependent tasks, waiting, and final aggregation', async () => { + it('runs two teammates, sibling messages, dependent tasks, waiting, and final aggregation', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-agent-team-headless-')) try { const home = join(cwd, '.dsh') @@ -27,15 +27,12 @@ describe('dsh run with Agent Teams enabled', () => { await writeFile(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-headless', private: true, - dependencies: { - '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^', - }, + dependencies: {}, dsh: { profile: { bundles: [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', - '@deepseek-ai/dsh-experimental-agent-team-profile', ], }, }, @@ -91,8 +88,8 @@ describe('dsh run with Agent Teams enabled', () => { const workflowChild = parsed.find(log => log.some(record => record.type === 'user/message' && JSON.stringify((record.data as { content: unknown }).content) === JSON.stringify([{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }]))) expect(workflowChild).toBeDefined() - expect(workflowChild!.find(record => record.type === 'subagent/descriptor')?.data) - .toMatchObject({ mode: 'continuable', provider: 'spawn' }) + const workflowDescriptor = workflowChild!.find(record => record.type === 'subagent/descriptor')?.data as Record + expect(workflowDescriptor).toMatchObject({ mode: 'continuable', provider: 'spawn' }) expect(workflowChild!.filter(record => record.type === 'user/message' && (record.data as { source: { kind: string } }).source.kind === 'user').map(record => record.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) @@ -102,7 +99,15 @@ describe('dsh run with Agent Teams enabled', () => { }) expect(root).toBeDefined() const eventTypes = root!.map(record => record.type) - expect(eventTypes.filter(type => type === 'team/member')).toHaveLength(4) + expect(eventTypes).not.toContain('team/member') + const catalog = root!.filter(record => record.type === 'subagent/catalog').map(record => record.data) + expect(catalog).toEqual(expect.arrayContaining([ + expect.objectContaining({ version: 0, childId: workflowChild![0]?.id }), + expect.objectContaining({ version: 0, label: 'Own deterministic implementation.' }), + expect.objectContaining({ version: 0, label: 'Own deterministic research.' }), + ])) + for (const entry of catalog) expect(entry).not.toHaveProperty('name') + expect(eventTypes).not.toContain('subagent-name/bound') expect(eventTypes).not.toContain('team/message/queued') expect(eventTypes).not.toContain('team/message/delivered') const peerMessages = parsed.flatMap(log => log.filter(record => record.type === 'user/message' diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index a97d158697..1c8765d268 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -344,6 +344,20 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { testProfileResolution('lib') + it.runIf(process.platform === 'win32')('resolves the installed entry before loading CLI dependencies', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-cli-alias-')) + try { + const alias = join(home, 'launcher') + symlinkSync(join(repoRoot, 'apps/cli/lib'), alias, 'junction') + const { stdout } = await execa(process.execPath, [ + '--preserve-symlinks-main', join(alias, 'bin.js'), '--version', + ], { timeout: SPAWN_TIMEOUT_MS, env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' } }) + expect(stdout).toBe(cliVersion) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, SPAWN_TIMEOUT_MS + 5_000) + it('requires a profile and rejects removed flags', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index b5a5eecbaf..8db33a3a87 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -171,8 +171,8 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('keeps the default host and minimal tool catalogs while every optional bundle is off', async () => { const minimal = await agent('minimal') const standard = await agent() - expect(names()).toEqual(['working_directory']) - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) + expect(names()).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) for (const name of ['str_replace_editor', 'session_search', 'terminal_open', 'ralph']) expect(names(standard.agent)).not.toContain(name) expect((await ctx.skills.list({ cwd: workspace, scope: standard.agent })).some(skill => skill.name === 'dsh-badge')).toBe(false) }) @@ -246,7 +246,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(toolResult(owner.agent, 'badge-on')).toContain('dsh-badge.png') const minimal = await agent('minimal') expect((await ctx.skills.list({ cwd: workspace, scope: minimal.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) await select(bundles.badge, false) expect((await load('badge-after')).isError).toBe(true) }) diff --git a/apps/cli/tests/profile-mcp.spec.ts b/apps/cli/tests/profile-mcp.spec.ts index b082d8316b..4fbc3de396 100644 --- a/apps/cli/tests/profile-mcp.spec.ts +++ b/apps/cli/tests/profile-mcp.spec.ts @@ -1,4 +1,4 @@ -/** MCP resource ownership across the resolved shipped profile templates. */ +/** MCP resource and server-manager ownership across the resolved shipped profile templates. */ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -41,4 +41,17 @@ describe('shipped MCP resource composition', () => { rmSync(home, { recursive: true, force: true }) } }) + + it.each(Object.keys(PROFILE_TEMPLATES))('%s mounts the server manager only with the Web application', (name) => { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-mcp-')) + try { + const profile = loadProfile('dsh', name, installAnchor, home) + const rows = composeEntries([...profile.layers.map(layer => layer.patches), profile.patches]) + expect(rows.filter(row => row.name === '@deepseek-ai/dsh-mcp-manager')).toEqual(name === 'web' + ? [{ id: 'mcp-manager', name: '@deepseek-ai/dsh-mcp-manager', inject: ['credentials'] }] + : []) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) }) diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl index 19495490be..8467970106 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl index 7e3a02fce9..a4ac54b8c1 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 259ba7341e..63715e4638 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -117,6 +117,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn }, }) expect(result.stderr).toBe('') + if (result.sessionId === undefined) throw new Error('image-offload scenario did not create a session') expect(requests).toHaveLength(2) expect(fileRequests).toEqual([{ method: 'POST', path: filesPath, bytes: 69 }]) const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' @@ -139,10 +140,13 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const runtimeContext = (cwd: string): string => 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + const runtimeContext = (cwd: string, sessionId: string): string => 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + `Current working directory: ${JSON.stringify(cwd)}.\n\n` + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' - + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' + + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\n' + + `You are the root agent. Your agent id is ${JSON.stringify(sessionId)}.\n\n` + + 'To message other agents, use send_message({ agent_id: "", message: "..." }).\n' + + 'Use list_agents({}) to find your collaborator agents and their ids.' const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ @@ -152,7 +156,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(result.cwd) }, + { type: 'text', text: runtimeContext(result.cwd, result.sessionId) }, ]) const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> @@ -188,6 +192,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn }, }) expect(fallback.stderr).toBe('') + if (fallback.sessionId === undefined) throw new Error('image-offload fallback scenario did not create a session') expect(fileRequests).toEqual([ { method: 'POST', path: filesPath, bytes: 69 }, { method: 'POST', path: filesPath, bytes: 69 }, @@ -203,7 +208,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(fallback.cwd) }, + { type: 'text', text: runtimeContext(fallback.cwd, fallback.sessionId) }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs index 5ca8db1ac6..4acea7354d 100644 --- a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs +++ b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs @@ -88,7 +88,7 @@ async function exercise(ctx, config) { // A transitive repository package the registry served, absent from npm at this version. const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version const provider = name.endsWith('-codex') ? 'codex' : 'claude-code' - const providerEnabled = ctx.subagents.list().includes(provider) + const providerEnabled = ctx.subagents.getProvider(provider) !== undefined const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name]) const binaryName = `${provider === 'codex' ? 'codex' : 'claude'}${process.platform === 'win32' ? '.exe' : ''}` const nativeBinary = (await readdir(nativeDir, { recursive: true })).find(path => basename(path) === binaryName) diff --git a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml index 7511e92167..83547c6412 100644 --- a/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/subagent-inheritance-snapshot.patch.yml @@ -40,3 +40,8 @@ - id: session-log-deepseek config: enabled: false + +# The recorded tool catalog includes the optional patch tool. +- id: tool-fs + config: + applyPatch: true diff --git a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml index dec80a9fe9..7a1d4c8a24 100644 --- a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml @@ -21,33 +21,7 @@ root: './.sessions' compression: none -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-report - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - - insert: - - id: agent-team - name: '@deepseek-ai/dsh-experimental-agent-team' - config: - maxMembers: 8 - maxTasks: 256 - maxMessageBytes: 65536 - disposalTimeoutMs: 5000 - - - id: tool-agent-team - name: '@deepseek-ai/dsh-experimental-tool-agent-team' - config: - freshProvider: spawn - forkProvider: fork - - id: team-fixture-llm name: './tests/fixtures/team-llm.mjs' diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index c54f84df0b..7779eb2181 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index d45d98dfd0..d4385b311d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}} {"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index e747f996ad..6cebc92a87 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index bc7c728199..8cc16706c1 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index 5792ae3774..8488837003 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 11ae2ec827..c14e345b7f 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -18,7 +18,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index 9da1c6379a..57f93111e7 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -16,13 +16,13 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [running] — root agent\n{{sessionId}} [inactive] parent={{sessionId}} depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index b0372d96c6..27712bcd00 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Delegated write probe","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash","agentReasoningEffort":"low"}} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index 3f3dee9c95..f8796478f5 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -16,20 +16,20 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} {"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index c07532bd7d..2305a8a7c2 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 441cab1aa2..f05ae06cef 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}} @@ -13,15 +13,15 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_SETTLEMENT_NOTICE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index cbb82cb603..56b127865a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index 3bd9c55f95..dfe1d171a9 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml index aa22a2d2f0..5b1f799910 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml @@ -42,8 +42,14 @@ mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() -# A search key reference no runner sets: the turn runs as a deployment without -# web search credentials, whatever the launching environment exports. +# Own key references no runner sets on the DeepSeek Search and Fetch APIs: the +# turn runs as a deployment without web search credentials, whatever +# DEEPSEEK_API_KEY the launching environment exports. +- id: web-deepseek-api + config: + searchApiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY + fetchApiKeyEnv: DSH_KEYLESS_SMOKE_FETCH_KEY + - id: web-search-deepseek config: apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 9c1bedd00e..62751cd93f 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -15,6 +15,12 @@ function latestAssistantCalls(messages) { return assistant?.content.filter(block => block.type === 'tool-call').map(block => block.name) ?? [] } +function hasNewCoordinationInput(messages) { + const toolIndex = messages.findLastIndex(message => message.role === 'tool') + return messages.some((message, index) => index > toolIndex && message.role === 'user' + && ['agent-message', 'subagent-settled'].includes(message.source?.kind)) +} + function hasTaskAction(messages, action) { return messages.some(message => message.role === 'assistant' && message.content.some((block) => { @@ -63,6 +69,11 @@ function textChunks(text) { function researcher(messages) { const names = calls(messages) + if (!names.includes('list_agents')) return toolChunks([{ name: 'list_agents', args: {} }]) + const sibling = messages.filter(message => message.role === 'tool') + .flatMap(message => message.content.filter(block => block.type === 'text').flatMap(block => block.text.split('\n'))) + .find(line => line.endsWith('— Own deterministic implementation.'))?.split(' ')[0] + if (sibling === undefined) throw new Error('list_agents did not expose the implementation sibling') if (!names.includes('team_task_create')) { return toolChunks([{ name: 'team_task_create', args: { subject: 'Research', description: 'Collect the deterministic finding.', write_scopes: ['research'], @@ -76,13 +87,13 @@ function researcher(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'implementer', message: 'Research complete: use the deterministic finding.' } }, + { name: 'send_message', args: { agent_id: sibling, message: 'Research complete: use the deterministic finding.' } }, ]) } return textChunks('Research teammate complete.') } -function implementer(messages) { +function implementer(messages, parentId) { const names = calls(messages) const last = latestAssistantCalls(messages) const text = latestToolText(messages) @@ -98,7 +109,7 @@ function implementer(messages) { write_scopes: ['implementation'], } }]) } - if (last.includes('wait_agent')) { + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) { return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) @@ -109,10 +120,10 @@ function implementer(messages) { task_id: 'task-2', expected_revision: 1, action: 'claim', } }]) } - if (last.includes('team_task_get')) { - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + if (last.includes('team_task_get') && !hasNewCoordinationInput(messages)) { + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) } - if (last.includes('wait_agent')) { + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) { return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) @@ -123,7 +134,7 @@ function implementer(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'lead', message: 'Implementation complete and verified.' } }, + { name: 'send_message', args: { agent_id: parentId, message: 'Implementation complete and verified.' } }, ]) } return textChunks('Implementation teammate complete.') @@ -138,26 +149,22 @@ function lead(messages) { script: 'return await agent("TEAM_WORKFLOW_CHILD");', } }]) } - const spawned = names.filter(name => name === 'spawn_teammate').length + const spawned = names.filter(name => name === 'subagent').length if (spawned === 0) { return toolChunks([{ - name: 'spawn_teammate', + name: 'subagent', args: { - name: 'implementer', description: 'Own deterministic implementation.', prompt: 'IMPLEMENTER_MARK: wait for research, complete dependent task 2, report to lead.', - context: 'fresh', }, }]) } if (spawned === 1) { return toolChunks([{ - name: 'spawn_teammate', + name: 'subagent', args: { - name: 'researcher', description: 'Own deterministic research.', prompt: 'RESEARCHER_MARK: complete research task 1, message implementer, then finish.', - context: 'fresh', }, }]) } @@ -165,35 +172,38 @@ function lead(messages) { if (last.includes('team_task_list')) { const completed = result.match(/"status":"completed"/gu)?.length ?? 0 if (completed >= 2) return toolChunks([{ name: 'list_agents', args: {} }]) - return toolChunks([{ name: 'team_task_list', args: {} }]) + return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'team_task_list' : 'wait_agent', + args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) } if (last.includes('list_agents')) { - const inactive = result.match(/"status":"inactive"/gu)?.length ?? 0 + const inactive = result.split('\n') + .filter(row => row.includes('— Own deterministic ') && row.includes('[inactive]')).length if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') - return toolChunks([{ name: 'list_agents', args: {} }]) + return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'list_agents' : 'wait_agent', + args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) } - if (last.includes('wait_agent')) return toolChunks([{ name: 'team_task_list', args: {} }]) + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) return toolChunks([{ name: 'team_task_list', args: {} }]) return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) } class TeamFixtureAdapter extends LlmAdapter { async * stream(options) { const tools = options.tools.map(tool => tool.name) - if (tools.includes('subagent') || tools.includes('subagent_fork')) { - throw new Error('Team profile exposes a direct subagent tool') - } const initial = options.messages.findLast(message => message.role === 'user' && message.source.kind === 'user') const workflowChild = initial?.content[0]?.text === 'TEAM_WORKFLOW_CHILD' const context = options.messages.findLast(message => message.source?.kind === 'runtime-context') - const identity = context?.source.sections?.find(section => section.name === 'team:identity')?.text - if (!workflowChild && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { - throw new Error('Team profile is missing teammate or workflow tools') + const section = name => context?.source.sections?.find(candidate => candidate.name === name)?.text + const role = section('subagent:identity') + const parentId = role?.match(/You are a subagent delegated by agent "([^"]+)"/u)?.[1] + const task = initial?.content.filter(block => block.type === 'text').map(block => block.text).join('\n') + if (!workflowChild && (!tools.includes('subagent') || !tools.includes('workflow') || !tools.includes('team_task_create'))) { + throw new Error('Team profile is missing delegation, workflow, or task tools') } let chunks if (workflowChild) chunks = textChunks('Fresh workflow child complete.') - else if (identity?.startsWith('You are teammate "researcher".')) chunks = researcher(options.messages) - else if (identity?.startsWith('You are teammate "implementer".')) chunks = implementer(options.messages) - else if (identity?.startsWith('You are the Team Lead named "lead".')) chunks = lead(options.messages) + else if (task?.startsWith('RESEARCHER_MARK:')) chunks = researcher(options.messages) + else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages, parentId) + else if (role?.startsWith('You are the root agent. Your agent id is')) chunks = lead(options.messages) else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { options.signal?.throwIfAborted() @@ -205,9 +215,30 @@ class TeamFixtureAdapter extends LlmAdapter { /** Cordis plugin name. */ export const name = 'team-fixture-llm' /** LLM registry dependency. */ -export const inject = ['llm'] +export const inject = ['llm', 'tools', 'agentTeams'] /** Register the keyless adapter on the shipped default provider route. */ export function apply(ctx) { + ctx.on('agent/created', async ({ agent }) => { + if (agent.session.header.parentSession === undefined) await ctx.agentTeams.setEnabled(agent, true) + }) + const implementerWaiting = Promise.withResolvers() + const leadWaiting = Promise.withResolvers() + for (const gate of [implementerWaiting, leadWaiting]) void gate.promise.catch(() => undefined) + ctx.effect(() => () => { + implementerWaiting.reject(new Error('Team fixture disposed before implementer wait')) + leadWaiting.reject(new Error('Team fixture disposed before lead wait')) + }) + ctx.on('tools/execute', async (exec, next) => { + if (exec.name === 'team_task_create' && exec.arguments.subject === 'Research') { + await Promise.all([implementerWaiting.promise, leadWaiting.promise]) + } + const result = next() + if (exec.name === 'wait_agent') { + if (exec.agent.session.header.origin === 'subagent') implementerWaiting.resolve() + else leadWaiting.resolve() + } + return result + }) ctx.llm.registerAdapter(['deepseek-official'], new TeamFixtureAdapter()) } diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index a2ae0c9f19..9f53fb980a 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -1093,7 +1093,7 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { + it('runs a keyless Agent Team with described teammates, sibling messages, dependent tasks, waiting, and Lead aggregation', async () => { let projection: unknown const result = await runLoaderSmoke({ label: 'Agent Teams headless snapshot', @@ -1125,8 +1125,8 @@ describe('headless stream-json snapshots', () => { expect(workflowRows.filter(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'user').map(row => row.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) - const members = rows.filter(row => row.type === 'team/member') - .map(row => ((row.data as JsonObject).member as JsonObject)) + const teammates = rows.filter(row => row.type === 'subagent/catalog') + .flatMap(row => typeof (row.data as JsonObject).label === 'string' && ((row.data as JsonObject).label as string).startsWith('Own deterministic ') ? [(row.data as JsonObject).label as string] : []) const tasks = rows.filter(row => row.type === 'team/task') .map(row => ((row.data as JsonObject).task as JsonObject)) const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task]))) @@ -1179,21 +1179,51 @@ describe('headless stream-json snapshots', () => { && ((row.data as JsonObject).source as JsonObject).kind === 'user') if (initial === undefined) throw new Error('Teammate Session has no initial task') const content = (initial.data as JsonObject).content as JsonObject[] - expect(content).toHaveLength(1) + // The task block is followed by the standard return guidance naming the Lead. + expect(content).toHaveLength(2) const context = parseJsonl(log.content).find(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'runtime-context') const source = (context?.data as JsonObject)?.source as JsonObject const sections = source?.sections as JsonObject[] | undefined - const identity = sections?.find(section => section.name === 'team:identity')?.text - if (typeof identity !== 'string') throw new Error('Teammate runtime context has no identity') - return [identity] + const leadId = parent.header.id as string + const text = (name: string) => { + const value = sections?.find(section => section.name === name)?.text + if (typeof value !== 'string') throw new Error(`Teammate runtime context has no ${name}`) + return value.replaceAll(log.header.id as string, '').replaceAll(leadId, '') + } + expect(sections?.some(section => section.name === 'team:identity')).toBe(false) + return [text('subagent:identity')] }).sort() + const allRows = logs.flatMap(log => parseJsonl(log.content)) + const messageSources = allRows.flatMap((row) => { + const data = row.data as JsonObject + if (row.type === 'user/message') return [(data.source as JsonObject).kind] + if (row.type === 'agent/inbox/spliced') { + return (data.inserted as JsonObject[]).map(message => (message.source as JsonObject).kind) + } + return [] + }) + expect(messageSources).not.toContain('team-task-change') + const waitIds = new Set(allRows.filter(row => row.type === 'tool/call' + && (row.data as JsonObject).name === 'wait_agent').map(row => (row.data as JsonObject).callId)) + const waitResults = allRows.filter((row) => { + if (row.type !== 'tool/result') return false + const message = (row.data as JsonObject).message as JsonObject + return waitIds.has((message.source as JsonObject).callId) + }) + expect(waitResults).toHaveLength(waitIds.size) + for (const row of waitResults) { + const message = (row.data as JsonObject).message as JsonObject + expect(message.isError).not.toBe(true) + const content = message.content as JsonObject[] + expect(JSON.parse(content.filter(block => block.type === 'text').map(block => block.text).join(''))) + .toEqual({ timedOut: false }) + } projection = { sessions: logs.length, workflowStopReason: (rows.find(row => row.type === 'tool-workflow/run-end')?.data as JsonObject)?.stopReason, - memberEdges: members.length, identities, - activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), + teammates: [...teammates].sort(), tasks: latestTasks.map(task => ({ subject: task.subject, revision: task.revision, @@ -1203,7 +1233,7 @@ describe('headless stream-json snapshots', () => { deliveredMessages: rows.filter(row => row.type === 'team/message/delivered').length, waited: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'wait_agent'), - checkedRoster: rows.some(row => row.type === 'tool/call' + checkedAgents: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'list_agents'), steerEvidence: { nextStepInbox: steeredInboxIndex >= 0, @@ -1221,25 +1251,22 @@ describe('headless stream-json snapshots', () => { }) expect(projection).toMatchInlineSnapshot(` { - "activeMembers": [ - "implementer", - "researcher", - ], - "checkedRoster": true, + "checkedAgents": true, "deliveredMessages": 0, "identities": [ - "You are teammate "implementer". - Your Team Lead is named "lead". - To message your Team Lead, use send_message({ target: "lead", message: "..." }). - Use list_agents({}) to find your teammates and their names. - To message another teammate, use send_message({ target: "", message: "..." }).", - "You are teammate "researcher". - Your Team Lead is named "lead". - To message your Team Lead, use send_message({ target: "lead", message: "..." }). - Use list_agents({}) to find your teammates and their names. - To message another teammate, use send_message({ target: "", message: "..." }).", + "You are a subagent delegated by agent "". + Your agent id is "". + The root agent of your collaboration tree is "". + + To message other agents, use send_message({ agent_id: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their ids.", + "You are a subagent delegated by agent "". + Your agent id is "". + The root agent of your collaboration tree is "". + + To message other agents, use send_message({ agent_id: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their ids.", ], - "memberEdges": 4, "queuedMessages": 0, "sessions": 4, "steerEvidence": { @@ -1260,6 +1287,10 @@ describe('headless stream-json snapshots', () => { "subject": "Research", }, ], + "teammates": [ + "Own deterministic implementation.", + "Own deterministic research.", + ], "waited": true, "workflowStopReason": "completed", } diff --git a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts index 0272ff8752..3710ec082a 100644 --- a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts @@ -61,7 +61,7 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch'])) - // Search has no key in this composition, so the model request does not offer it. + // Neither DeepSeek search provider has a key in this composition, so the model request does not offer search. expect(persistedToolNames).not.toContain('web_search') expect(persistedToolNames).not.toContain('str_replace_editor') }, PRODUCTION_PROFILE_TEST_TIMEOUT_MS) diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index 941d95c31d..3aff474038 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -1,6 +1,6 @@ /** * Assembled-app regression: a parent-owned catalog event remains sufficient - * for direct discovery when the persisted child has no descriptor event. + * for descendant discovery when the persisted child has no descriptor event. */ import { readFile, readdir, writeFile } from 'node:fs/promises' @@ -42,7 +42,7 @@ async function expectSession(actual: string, expectedPath: string): Promise { const ctx = new Context() @@ -106,7 +106,7 @@ async function seedCataloguedDescriptorlessChild(root: string, cwd: string): Pro } describe('parent-owned cold child catalog snapshot', () => { - it('discovers a catalogued child without reading its descriptor through the assembled headless app', async () => { + it('discovers a descriptorless catalogued child with its tree position through the assembled headless app', async () => { let cwd = '' const result = await runLoaderSmoke({ label: 'subagent diagnostic headless stream-json snapshot', @@ -131,9 +131,8 @@ describe('parent-owned cold child catalog snapshot', () => { const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"')) if (parent === undefined) throw new Error('missing persisted parent log') - // THE model-visible fact: the parent catalog supplies discovery even - // though consulting the descriptor-less child would classify it corrupt. - expect(parent).toContain(`${childId} [inactive] — descriptorless child`) + // Discovery reads child catalogs without requiring activation descriptors. + expect(parent).toContain(`${childId} [inactive] parent=${parentId} depth=1 — descriptorless child`) expect(parent).not.toContain('[diagnostic: corrupt]') const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } diff --git a/apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts b/apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts new file mode 100644 index 0000000000..480ffe2032 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/desktop-tools.expected.e2e.ts @@ -0,0 +1,162 @@ +/** + * Keyless comparison of actual SDK and Desktop/Web step-admitted tool sets. + * Keep intentional differences in tool-parity.ts; unused exceptions fail. + * Team mode is excluded while its known composition mismatch is tracked in #7172. + * SDK minimal, Creator-only tools, graphical context and optional runtime payloads + * are outside this fixed standard/PTC, top-level-session comparison. + */ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { z } from 'zod' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { expect, it, type TestContext } from 'vitest' +import { profileEnvironment } from './fixtures/profile-environment.ts' +import { messagesResponse } from './fixtures/messages-response.ts' +import { assertToolParity, type ToolMode } from './tool-parity.ts' + +const repo = fileURLToPath(new URL('../../../../../', import.meta.url)) +const observer = new URL('./fixtures/tool-parity.mjs', import.meta.url).href +const probe = new URL('./fixtures/parity-probe.mjs', import.meta.url).href +const inventorySchema = z.object({ names: z.array(z.string()).min(1), hmr: z.boolean() }) +type Inventory = z.infer + +async function fixture(test: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'sdk-desktop-tools-')) + const requests: { tools: { name: string }[] }[] = [] + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as { tools: { name: string }[] }) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse({ type: 'text', text: 'tools inspected' }, 'end_turn')) + }) + }) + test.onTestFinished(async () => { + try { + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + } + finally { await rm(root, { recursive: true, force: true }) } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no TCP address') + const env = profileEnvironment(root, `http://127.0.0.1:${address.port}`) + + async function collect(side: 'sdk' | 'desktop', mode: ToolMode, mutations: object[] = []): Promise { + const cwd = await mkdtemp(join(root, `${side}-${mode}-`)) + const output = join(cwd, 'tools.json') + const patch = join(cwd, 'observer.patch.yml') + await writeFile(patch, JSON.stringify([ + { id: 'session-title-llm', disabled: true }, + { insert: [{ id: 'tool-parity-observer', name: observer, + config: { output, cwd, ...(side === 'desktop' ? { preset: mode } : {}) } }] }, + ...mutations, + ])) + const before = requests.length + let inventory: Inventory + if (side === 'sdk') { + const harness = new DeepSeekHarness({ + cwd, processCwd: cwd, dshHome: join(cwd, 'home'), env, model: 'deepseek-v4-pro', + patches: [...mode === 'ptc' ? [join(repo, 'apps/cli/config/examples/ptc.yml')] : [], patch], + }) + const abort = (): void => { void harness.close() } + test.signal.addEventListener('abort', abort, { once: true }) + try { + const result = await harness.session('tool-parity').run('Inspect tools') + expect(result.finalResponse).toBe('tools inspected') + inventory = inventorySchema.parse(JSON.parse(await readFile(output, 'utf8'))) + } finally { + await harness.close() + test.signal.removeEventListener('abort', abort) + } + expect(inventory.hmr).toBe(false) + } else { + const child = execa(process.execPath, [join(repo, 'apps/cli/lib/bin.js'), '--profile', 'web', + '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'], { + cwd, env: { ...env, DSH_HOME: join(cwd, 'home') }, extendEnv: false, + ipc: true, stdin: 'ignore', reject: false, cancelSignal: test.signal, forceKillAfterDelay: 10_000, + }) + let stdout = '' + let stderr = '' + const ready = Promise.withResolvers() + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + if (stdout.includes('dsh web: http://')) ready.resolve(true) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + void child.then((result) => { ready.reject(new Error(`Web exited: ${result.exitCode}\n${stderr}`)) }) + try { + await ready.promise + const reply = child.getOneMessage() + await child.sendMessage('collect-tool-parity') + const message = z.object({ result: inventorySchema.optional(), error: z.string().optional() }).parse(await reply) + if (message.error !== undefined) throw new Error(message.error) + inventory = inventorySchema.parse(message.result) + } catch (error) { + throw new Error(`Web collection failed\n${stdout}\n${stderr}`, { cause: error }) + } finally { + // Ask the CLI to drain on Windows too, where an OS signal kills immediately. + const watchdog = setTimeout(() => { child.kill('SIGKILL') }, 10_000) + try { + try { await child.sendMessage('stop-tool-parity') } + catch (_closedChannel: unknown) { + // A closed IPC channel cannot request graceful shutdown; fall back to the OS signal. + child.kill('SIGTERM') + } + const result = await child + expect(result.exitCode, result.stderr).toBe(0) + expect(result.signal, result.stderr).toBeUndefined() + } finally { clearTimeout(watchdog) } + } + expect(inventory.hmr).toBe(true) + } + expect(requests.length).toBeGreaterThan(before) + const presented = requests[before]!.tools.map(tool => tool.name).sort() + if (mode === 'ptc') expect(presented).toEqual(['run_code']) + else expect(presented).toEqual(inventory.names) + return inventory + } + return collect +} + +it.for(['standard', 'ptc'] as const)('compares the shipped %s tool sets and permits explicit opt-out', async (mode, test) => { + const collect = await fixture(test) + const sdk = await collect('sdk', mode) + const desktop = await collect('desktop', mode) + assertToolParity(mode, sdk.names, desktop.names) + expect(sdk.names).toContain('plugin_manager') + const optOut = await collect('sdk', mode, [{ id: 'tool-plugin-manager', disabled: true }]) + expect(optOut.names).not.toContain('plugin_manager') + expect(() => { assertToolParity(mode, optOut.names, desktop.names) }).toThrow( + mode === 'ptc' ? 'Stale exceptions: sdk:plugin_manager' : 'Missing from SDK: plugin_manager') +}) + +it.for(['standard', 'ptc'] as const)('detects missing shared tools in %s', async (mode, test) => { + const collect = await fixture(test) + const desktop = await collect('desktop', mode) + for (const [id, tool] of [['present', 'present'], ['tool-ask-user', 'ask_user_question'], ['tool-schedule', 'schedule_create']]) { + const missing = await collect('sdk', mode, [{ id, disabled: true }]) + expect(() => { assertToolParity(mode, missing.names, desktop.names) }).toThrow(new RegExp(`Missing from SDK:.*${tool}`)) + } +}) + +it.for(['standard', 'ptc'] as const)('detects one-sided additions in %s', async (mode, test) => { + const collect = await fixture(test) + const sdk = await collect('sdk', mode) + const desktop = await collect('desktop', mode) + const addition = [{ insert: [{ id: 'parity-probe', name: probe }] }] + const desktopExtra = await collect('desktop', mode, addition) + expect(() => { assertToolParity(mode, sdk.names, desktopExtra.names) }).toThrow('Missing from SDK: parity_probe') + const sdkExtra = await collect('sdk', mode, addition) + expect(() => { assertToolParity(mode, sdkExtra.names, desktop.names) }).toThrow('Only in SDK: parity_probe') + assertToolParity(mode, sdkExtra.names, desktopExtra.names) +}) diff --git a/apps/cli/tests/profiles/sdk/fixtures/parity-probe.mjs b/apps/cli/tests/profiles/sdk/fixtures/parity-probe.mjs new file mode 100644 index 0000000000..03596ae9bc --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/parity-probe.mjs @@ -0,0 +1,12 @@ +/** A real extra registration used to detect one-sided profile additions. */ +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const inject = ['tools'] + +export function apply(ctx) { + ctx.tools.register(defineTool({ + name: 'parity_probe', description: 'Test profile addition.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute() { return 'probe' }, + })) +} diff --git a/apps/cli/tests/profiles/sdk/fixtures/profile-environment.ts b/apps/cli/tests/profiles/sdk/fixtures/profile-environment.ts new file mode 100644 index 0000000000..c520b3c0ff --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/profile-environment.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path' +import { clearAmbientProxyEnv } from '../../../../../../scripts/test-proxy-environment.ts' + +/** Isolate SDK profile processes from ambient profile, runtime and Node loader settings. */ +export function profileEnvironment(root: string, baseUrl: string): NodeJS.ProcessEnv { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('DSH_'))) + Object.assign(env, { + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DSH_AGENTS_HOME: join(root, 'agents'), DSH_PRIMARY_RUNTIME: '', + DEEPSEEK_API_KEY: 'keyless-profile-test', DEEPSEEK_BASE_URL: baseUrl, + NODE_OPTIONS: '', NODE_PATH: '', + }) + clearAmbientProxyEnv(env) + return env +} diff --git a/apps/cli/tests/profiles/sdk/fixtures/tool-parity.mjs b/apps/cli/tests/profiles/sdk/fixtures/tool-parity.mjs new file mode 100644 index 0000000000..69e1fbde57 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/fixtures/tool-parity.mjs @@ -0,0 +1,54 @@ +/** Observe step-admitted capabilities without changing either shipped tool executor. */ +import { writeFile } from 'node:fs/promises' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +export const inject = ['tools', 'agents', 'loader'] + +export function apply(ctx, config) { + let observed + ctx.on('system-prompt/assemble', async (_assembly, context, next) => { + if (context.toolSet !== undefined) { + observed = { + names: ctx.tools.definitions(context.scope, context.toolSet).map(tool => tool.name).sort(), + hmr: ctx.get('hmr') !== undefined, + } + await writeFile(config.output, JSON.stringify(observed)) + } + return next() + }) + if (config.preset === undefined) return + const receive = message => { + if (message === 'stop-tool-parity') { + // Run the CLI shutdown handler: Windows OS signals terminate before its cleanup. + process.emit('SIGTERM') + return + } + if (message !== 'collect-tool-parity') return + void collect().then( + result => process.send({ result }), + error => process.send({ error: String(error.stack ?? error) }), + ) + } + ctx.effect(() => { + process.on('message', receive) + return () => process.off('message', receive) + }) + async function collect() { + await ctx.loader.await() + const presets = ctx.get('agentPresets') + if (presets === undefined) throw new Error('Web tool parity requires the shipped presets') + const handle = await ctx.agents.create({ + sessionId: 'tool-parity', cwd: config.cwd, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + setup: scope => presets.mount(scope, config.preset).then(() => undefined), + }) + try { + handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Inspect tools' }], source: { kind: 'user' } })) + await handle.agent.whenIdle() + if (observed === undefined) throw new Error('Web did not assemble a model step') + return observed + } finally { + await handle.dispose() + } + } +} diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 6f8add8c49..81c9eb56ea 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -57,13 +57,22 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, + { label: 'allows an explicit patch to enable apply_patch', envValue: undefined, editorEnabled: false, patchEnabled: true }, { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, + // The shipped sdk profile mounts no observation policy: an edit of a file the model never read lands on disk. + { label: 'edits a file the model never read through the shipped profile', envValue: undefined, editorEnabled: false, unreadEdit: true }, ])('$label', async (scenario) => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc + const unreadEdit = 'unreadEdit' in scenario && scenario.unreadEdit + const patchEnabled = 'patchEnabled' in scenario && scenario.patchEnabled const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-')) + const unreadFile = join(root, 'notes.txt') + if (unreadEdit) await writeFile(unreadFile, 'alpha\n') const editorPatch = join(root, 'editor.patch.yml') + const fsPatch = join(root, 'fs.patch.yml') + if (patchEnabled) await writeFile(fsPatch, '- {id: tool-fs, config: {applyPatch: true}}\n') if (editorEnabled) await writeFile(editorPatch, [ '- insert:', ' - id: tool-str-replace-editor', @@ -78,7 +87,14 @@ describe('Python SDK dsh profile keyless smoke', () => { request.on('end', () => { modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end(messagesResponse({ type: 'text', text: 'done' }, 'max_tokens')) + if (unreadEdit && modelRequests.length === 1) { + response.end(messagesResponse({ + type: 'tool_use', id: 'unread-edit', name: 'edit', + input: { file_path: 'notes.txt', old_string: 'alpha', new_string: 'beta' }, + }, 'tool_use')) + return + } + response.end(messagesResponse({ type: 'text', text: 'done' }, unreadEdit ? 'end_turn' : 'max_tokens')) }) }) await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) @@ -91,6 +107,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(patchEnabled ? ['--patch', fsPatch] : []), ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, @@ -164,23 +181,29 @@ describe('Python SDK dsh profile keyless smoke', () => { sessionId: 'main', event: { type: 'turn/end', - data: { reason: { kind: 'max-tokens' } }, + data: { reason: { kind: unreadEdit ? 'completed' : 'max-tokens' } }, }, }, }) + if (unreadEdit) { + expect(modelRequests).toHaveLength(2) + expect(await readFile(unreadFile, 'utf8')).toBe('beta\n') + } expect(modelRequests[0]?.tools).toEqual(expect.any(Array)) const tools = modelRequests[0]?.tools as { name?: string }[] const toolNames = tools.map(tool => tool.name) expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' }) expect(modelRequests[0]?.max_tokens).toBe(1234) const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork', - 'present', 'ask_user_question', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update'] + 'present', 'ask_user_question', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'plugin_manager'] const requestText = JSON.stringify(modelRequests[0]) if (ptc) { expect(toolNames).toEqual(['run_code']) for (const tool of expectedTools) expect(requestText.includes(`${tool}:`), tool).toBe(true) + expect(requestText.includes('apply_patch:')).toBe(false) } else { expect(toolNames).toEqual(expect.arrayContaining(expectedTools)) + expect(toolNames.includes('apply_patch')).toBe(patchEnabled) expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled) expect(toolNames).not.toContain('list_subagent_models') } diff --git a/apps/cli/tests/profiles/sdk/plugin-manager.expected.e2e.ts b/apps/cli/tests/profiles/sdk/plugin-manager.expected.e2e.ts new file mode 100644 index 0000000000..e27881ded0 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/plugin-manager.expected.e2e.ts @@ -0,0 +1,176 @@ +/** SDK plugin management persists changes while the live stdio process keeps its original composition. */ +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client' +import { expect, it } from 'vitest' +import { z } from 'zod' +import { profileEnvironment } from './fixtures/profile-environment.ts' +import { messagesResponse } from './fixtures/messages-response.ts' + +it.for(['standard', 'ptc'] as const)('keeps %s plugin changes pending until the SDK runtime restarts', async (mode, test) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-plugin-manager-')) + const requests: Record[] = [] + let phase: 'list' | 'set' | 'done' = 'list' + let changedAt = 0 + let fixtureError: unknown + const listPage = z.object({ + entries: z.array(z.object({ entryId: z.string(), moduleName: z.string() })), nextOffset: z.number().nullable(), + }) + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + let args: Record | undefined + try { + const current = JSON.parse(body) as Record + requests.push(current) + if (requests.length === 1) args = { action: 'list_plugins', limit: 100 } + else if (phase === 'list') { + const messages = current.messages as { content: { type: string; content?: { text: string }[] }[] }[] + const result = messages.flatMap(message => message.content).findLast(block => block.type === 'tool_result') + const page = listPage.parse(JSON.parse(result!.content![0]!.text)) + const row = page.entries.find(entry => entry.moduleName === '@deepseek-ai/dsh-tool-web') + if (row !== undefined) { + args = { action: 'set_plugin', target: row.entryId, enabled: false } + phase = 'set' + } else if (page.nextOffset !== null) args = { action: 'list_plugins', offset: page.nextOffset, limit: 100 } + else throw new Error('SDK plugin inventory omitted tool-web') + } else if (phase === 'set') { + changedAt = requests.length - 1 + phase = 'done' + } + } catch (error) { fixtureError = error } + const call = mode === 'ptc' + ? { type: 'tool_use', id: `manage-${requests.length}`, name: 'run_code', input: { + code: `return await tools.plugin_manager(${JSON.stringify(args)});`, description: 'Disable web tools after restart', + } } + : { type: 'tool_use', id: `manage-${requests.length}`, name: 'plugin_manager', input: args } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(args !== undefined ? call : { type: 'text', text: 'done' }, + args !== undefined ? 'tool_use' : 'end_turn')) + }) + }) + test.onTestFinished(async () => { + try { + await new Promise((resolve, reject) => server.close((error) => { + if (error === undefined) resolve() + else reject(error) + })) + } + finally { await rm(root, { recursive: true, force: true }) } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no TCP address') + const env = profileEnvironment(root, `http://127.0.0.1:${address.port}`) + const options = { + cwd: root, processCwd: root, dshHome: join(root, 'home'), env, model: 'deepseek-v4-pro', + patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [], + } + async function run(restarted: boolean): Promise { + const harness = new DeepSeekHarness(options) + const abort = (): void => { void harness.close() } + test.signal.addEventListener('abort', abort, { once: true }) + try { + const session = harness.session(restarted ? 'restarted' : 'original') + expect((await session.run('Disable web tools after restart')).finalResponse).toBe('done') + if (!restarted) { + expect((await session.run('Continue on the same connection')).finalResponse).toBe('done') + } + } finally { + await harness.close() + test.signal.removeEventListener('abort', abort) + } + } + const advertises = (request: Record, name: string): boolean => mode === 'ptc' + ? JSON.stringify(request.system).includes(`${name}:`) + : (request.tools as { name: string }[]).some(tool => tool.name === name) + await run(false) + expect(fixtureError).toBeUndefined() + expect(changedAt).toBeGreaterThan(0) + expect(advertises(requests[0]!, 'plugin_manager')).toBe(true) + expect(JSON.stringify(requests[changedAt]!.messages)).toContain('restart-required') + expect(JSON.stringify(requests[changedAt]!.messages)).toContain('no-hmr') + expect(JSON.stringify(requests[changedAt]!.messages)).toContain('restart DSH') + expect(advertises(requests.at(-1)!, 'web_fetch')).toBe(true) + const saved = await readFile(join(root, 'home/profiles/sdk/cordis.patch.yml'), 'utf8') + expect(saved).toContain('tool-web') + expect(saved).toContain('disabled: true') + await run(true) + expect(advertises(requests.at(-1)!, 'web_fetch')).toBe(false) + expect(advertises(requests.at(-1)!, 'plugin_manager')).toBe(true) +}) + +it.for(['unavailable', 'approved', 'missing-pnpm'] as const)('reports the %s plugin-management prerequisite', async (scenario, test) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-plugin-prerequisite-')) + const requests: Record[] = [] + let fixtureError: unknown + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + try { requests.push(JSON.parse(body) as Record) } + catch (error) { fixtureError = error } + const args = scenario === 'missing-pnpm' + ? { action: 'install_bundle', target: '@deepseek-ai/dsh-subagent-codex' } + : { action: 'list_plugins', limit: 1 } + const call = { type: 'tool_use', id: 'prerequisite', name: 'plugin_manager', input: args } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(requests.length === 1 ? call : { type: 'text', text: 'done' }, + requests.length === 1 ? 'tool_use' : 'end_turn')) + }) + }) + test.onTestFinished(async () => { + try { await new Promise((resolve, reject) => server.close((error) => { if (error) reject(error); else resolve() })) } + finally { await rm(root, { recursive: true, force: true }) } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no TCP address') + const env = profileEnvironment(root, `http://127.0.0.1:${address.port}`) + env.DSH_PERMISSION_MODE = scenario === 'missing-pnpm' ? 'danger-full-access' : 'workspace-write' + if (scenario === 'missing-pnpm') { + for (const key of Object.keys(env)) if (key.toLowerCase() === 'path') Reflect.deleteProperty(env, key) + env.PATH = root + } + const harness = new DeepSeekHarness({ cwd: root, processCwd: root, dshHome: join(root, 'home'), + env, model: 'deepseek-v4-pro', approvalRequests: scenario === 'approved' }) + let approvals = 0 + const abort = (): void => { void harness.close() } + test.signal.addEventListener('abort', abort, { once: true }) + try { + harness.client.start() + harness.client.onRequest(async (method) => { + expect(method).toBe('approval/request') + approvals++ + return { outcome: 'allowed-once' } + }) + expect((await harness.run('Check plugin prerequisites')).finalResponse).toBe('done') + expect(fixtureError).toBeUndefined() + const result = JSON.stringify(requests.at(-1)?.messages) + if (scenario === 'unavailable') expect(result).toContain('unavailable') + else if (scenario === 'missing-pnpm') { + const messages = requests.at(-1)!.messages as { content: { type: string; content?: { text: string }[] }[] }[] + const toolResult = messages.flatMap(message => message.content).findLast(block => block.type === 'tool_result') + const failure = z.object({ + changed: z.literal(false), application: z.literal('failed'), + packageResult: z.object({ exitCode: z.number(), output: z.string().min(1) }), + error: z.object({ code: z.literal('operation-error'), diagnostic: z.string().min(1) }), + }).parse(JSON.parse(toolResult!.content![0]!.text)) + // cmd.exe reports 1 for a missing command; POSIX spawn reports 127. + expect(failure.packageResult.exitCode).toBe(process.platform === 'win32' ? 1 : 127) + expect(failure.error.diagnostic).toContain('pnpm') + expect(failure.packageResult.output).toContain(failure.error.diagnostic) + } else { expect(approvals).toBe(1); expect(result).toContain('entries') } + const manifest = JSON.parse(await readFile(join(root, 'home/profiles/sdk/package.json'), 'utf8')) as { dependencies: object } + expect(Object.keys(manifest.dependencies ?? {})).toEqual([]) + } finally { + await harness.close() + test.signal.removeEventListener('abort', abort) + } +}) diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts index f3efcf6dd1..a4cb88f925 100644 --- a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -1,4 +1,4 @@ -/** Reminder activation must share SDK ownership and initialized model limits after process restart. */ +/** Schedule authoring and restart delivery must honor the shipped SDK composition. */ import { createServer } from 'node:http' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -30,7 +30,8 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t if (restarting) restored.push(request) const content = requests.length === 1 ? { type: 'tool_use', id: 'create-reminder', name: 'schedule_create', - input: { title: 'Check SDK', prompt: 'Report the SDK reminder', after_seconds: 86400 }, + input: { title: 'Check SDK', prompt: 'Report the SDK reminder', + rrule: 'DTSTART:20300102T000000Z\nRRULE:FREQ=DAILY;COUNT=1', target: 'this_session' }, } : { type: 'text', text: 'ready' } response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end(messagesResponse(content, requests.length === 1 ? 'tool_use' : 'end_turn')) @@ -56,7 +57,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t model: 'deepseek-v4-pro', maxTokens: 5678, initializeTimeoutMs: 30_000, env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-01T00:00:00Z')) } }) clients.push(first) - expect((await first.session('saved').run('Set a reminder')).finalResponse).toBe('ready') + expect(await first.session('saved').run('Set a reminder')).toMatchObject({ finalResponse: 'ready' }) expect(requests).toHaveLength(2) await first.close() restarting = true @@ -68,5 +69,66 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t await vi.waitFor(() => { expect(restored).toHaveLength(1) }, { timeout: 30_000 }) expect(restored[0]).toMatchObject({ model: 'deepseek-v4-flash', max_tokens: 1234 }) const session = await second.resumeSession('saved') - expect((await session.run('Continue after the reminder')).finalResponse).toBe('ready') + expect(await session.run('Continue after the reminder')).toMatchObject({ finalResponse: 'ready' }) +}) + +it('refuses new Session destinations before storing tasks in the shipped SDK profile', async () => { + const root = await mkdtemp(join(tmpdir(), 'sdk-schedule-targets-')) + const requests: Record[] = [] + const calls = ['new', 'new_once', 'this_session'].flatMap(target => [ + { type: 'tool_use', id: `create-${target}`, name: 'schedule_create', + input: { title: `SDK ${target}`, prompt: 'Check SDK', + rrule: 'DTSTART:20300102T000000Z\nRRULE:FREQ=DAILY;COUNT=1', target } }, + { type: 'tool_use', id: `list-${target}`, name: 'schedule_list', input: {} }, + ]) + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { body += chunk }) + incoming.on('end', () => { + const content = calls[requests.length] ?? { type: 'text', text: 'ready' } + requests.push(JSON.parse(body) as Record) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(content, content.type === 'tool_use' ? 'tool_use' : 'end_turn')) + }) + }) + const clients: DeepSeekHarness[] = [] + onTestFinished(async () => { + try { await Promise.all(clients.map(client => client.close())) } finally { + try { + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } finally { await rm(root, { recursive: true, force: true }) } + } + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture has no port') + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } + clearAmbientProxyEnv(env) + const client = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), + model: 'deepseek-v4-pro', initializeTimeoutMs: 30_000, env }) + clients.push(client) + expect((await client.session('schedule-owner').run('Check the supported schedule destinations')).finalResponse).toBe('ready') + expect(requests).toHaveLength(calls.length + 1) + const results = calls.map((call, index) => { + const messages = requests[index + 1]?.messages as { + content: { type: string; tool_use_id?: string; content?: { type: string; text?: string }[] }[] + }[] + const result = messages.flatMap(message => message.content) + .find(block => block.type === 'tool_result' && block.tool_use_id === call.id) + const text = result?.content?.find(block => block.type === 'text')?.text + if (text === undefined) throw new Error(`SDK model request has no result for ${call.id}`) + return JSON.parse(text) as Record + }) + for (const index of [0, 2]) { + expect(results[index]).toMatchObject({ + code: 'invalid_rule', message: expect.stringContaining('choose an existing Session target') as string, + }) + expect(results[index + 1]).toMatchObject({ items: [], shown: 0, total: 0 }) + } + expect(results[4]).toMatchObject({ title: 'SDK this_session', target: { kind: 'session', sessionId: 'schedule-owner' } }) + expect(results[5]).toMatchObject({ items: [results[4]], shown: 1, total: 1 }) }) diff --git a/apps/cli/tests/profiles/sdk/tool-parity.spec.ts b/apps/cli/tests/profiles/sdk/tool-parity.spec.ts new file mode 100644 index 0000000000..7e3e5a8585 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/tool-parity.spec.ts @@ -0,0 +1,26 @@ +/** Diagnostics and exact exception consumption for cross-profile tool comparison. */ +import { expect, it } from 'vitest' +import { assertToolParity } from './tool-parity.ts' + +it('accepts an exact directional exception and rejects it after the profiles converge', () => { + const exceptions = [{ tool: 'desktop_only', side: 'desktop' as const, reason: 'Requires a graphical client.' }] + expect(() => { assertToolParity('standard', ['read'], ['read', 'desktop_only'], exceptions) }).not.toThrow() + expect(() => { assertToolParity('standard', ['read'], ['read'], exceptions) }).toThrow('Stale exceptions: desktop:desktop_only') + expect(() => { assertToolParity('standard', ['read', 'desktop_only'], ['read'], exceptions) }).toThrow('Only in SDK: desktop_only') +}) + +it('reports unexpected differences in both directions without hiding shared additions', () => { + expect(() => { assertToolParity('standard', ['read', 'new_shared'], ['new_shared', 'read'], []) }).not.toThrow() + expect(() => { assertToolParity('ptc', ['read', 'sdk_extra'], ['read', 'present'], []) }).toThrow( + 'Missing from SDK: present\nOnly in SDK: sdk_extra', + ) +}) + +it('rejects empty observations, undocumented exceptions and duplicate exceptions', () => { + expect(() => { assertToolParity('standard', [], [], []) }).toThrow('Empty tool inventory') + expect(() => { assertToolParity('standard', ['read'], ['read'], [ + { tool: 'extra', side: 'sdk', reason: ' ' }, + ]) }).toThrow('requires a reason') + const exception = { tool: 'extra', side: 'sdk' as const, reason: 'Intentional.' } + expect(() => { assertToolParity('standard', ['read', 'extra'], ['read'], [exception, exception]) }).toThrow('Stale exceptions: sdk:extra') +}) diff --git a/apps/cli/tests/profiles/sdk/tool-parity.ts b/apps/cli/tests/profiles/sdk/tool-parity.ts new file mode 100644 index 0000000000..fce089fc02 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/tool-parity.ts @@ -0,0 +1,53 @@ +/** Exact, directional exceptions to the shipped SDK/Desktop capability comparison. */ +export type ToolMode = 'standard' | 'ptc' + +interface Exception { + tool: string + side: 'sdk' | 'desktop' + reason: string +} + +/** Intentional product differences; obsolete entries fail alongside undeclared differences. */ +export const toolParityExceptions: Record = { + standard: [{ tool: 'list_subagent_models', side: 'desktop', + reason: 'Desktop enables subagent model discovery; SDK delegation inherits the caller model by default.' }], + ptc: [ + { tool: 'list_subagent_models', side: 'desktop', + reason: 'Desktop enables subagent model discovery; SDK delegation inherits the caller model by default.' }, + { tool: 'plugin_manager', side: 'sdk', + reason: 'SDK PTC changes are persisted for restart without replacing active bindings because HMR is disabled; Desktop PTC excludes management in its HMR-enabled preset.' }, + { tool: 'workflow', side: 'sdk', + reason: 'The SDK PTC overlay changes only the executor so existing base workflow callers keep working; Desktop PTC uses run_code orchestration and omits the separate workflow surface.' }, + ], +} + +/** + * Compare actual step-admitted sets, rejecting both drift and unused exceptions. + * @param mode - matching SDK executor and Desktop preset. + * @param sdk - SDK capability names. + * @param desktop - Desktop capability names. + * @param exceptions - exact one-sided product differences with their rationale. + */ +export function assertToolParity( + mode: ToolMode, sdk: readonly string[], desktop: readonly string[], + exceptions = toolParityExceptions[mode], +): void { + if (sdk.length === 0 || desktop.length === 0) throw new Error(`Empty tool inventory: ${mode}`) + const only = { + sdk: new Set(sdk.filter(name => !desktop.includes(name))), + desktop: new Set(desktop.filter(name => !sdk.includes(name))), + } + const stale: string[] = [] + for (const exception of exceptions) { + if (!exception.reason.trim()) throw new Error(`Tool exception requires a reason: ${exception.tool}`) + if (!only[exception.side].delete(exception.tool)) stale.push(`${exception.side}:${exception.tool}`) + } + if (only.sdk.size || only.desktop.size || stale.length) { + throw new Error([ + `SDK/Desktop tool mismatch: ${mode}`, + `Missing from SDK: ${[...only.desktop].sort().join(', ') || '(none)'}`, + `Only in SDK: ${[...only.sdk].sort().join(', ') || '(none)'}`, + `Stale exceptions: ${stale.sort().join(', ') || '(none)'}`, + ].join('\n')) + } +} diff --git a/apps/cli/tests/profiles/web/tests/default-web-process.ts b/apps/cli/tests/profiles/web/tests/default-web-process.ts index 537ac77702..155f8d6670 100644 --- a/apps/cli/tests/profiles/web/tests/default-web-process.ts +++ b/apps/cli/tests/profiles/web/tests/default-web-process.ts @@ -55,7 +55,7 @@ export async function withDefaultWeb( await writeFile(patch, JSON.stringify([{ insert: [{ id: 'runtime-roster-observer', name: pathToFileURL(join(root, 'fixtures/runtime-roster-observer.js')).href, - config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-agent-team/lib/index.js')).href }, + config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-voice-input/lib/index.js')).href }, }] }]) + '\n') const launch = resolveExampleLaunch({ srcBin: join(repoRoot, 'apps/cli/src/bin.ts'), diff --git a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts index 063f578de9..b307a9ee8e 100644 --- a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts @@ -1,14 +1,18 @@ -/** Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence. */ +/** + * Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence. + * Existing rows load without writes; edits keep the envelope and store the current target shape. + */ import { randomUUID } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { DailyScheduleRecord, ScheduleCatalogEntry, ScheduleTask } from '@deepseek-ai/dsh-schedule' import { expect, it } from 'vitest' import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts' const id = 'retained-remote-task' as DailyScheduleRecord['id'] -const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId'] +const sessionId = 'unloaded-owner' as SessionId const record: DailyScheduleRecord = { id, kind: 'daily', title: 'Retained instruction', prompt: 'Retained instruction\nwith a second line', time: '09:00:00.125', timeZone: 'Asia/Shanghai', scheduledAt: '2099-01-01T01:00:00.125Z', @@ -20,15 +24,33 @@ const task = { sessionId, record, status: 'active', lastDelivery, deliveryHistory: { earlierRecordsUnavailable: false, records: [{ ...lastDelivery, prompt: record.prompt }] }, } -const taskPath = (root: string) => join(root, 'home', 'storages', 'schedule.json') - -async function seed(root: string, bytes: string): Promise { - await mkdir(join(root, 'home', 'storages'), { recursive: true }) - await writeFile(taskPath(root), bytes) +const source = `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: task } } }, null, 2)}\n` +const sourcePath = (root: string) => join(root, 'home', 'storages', 'schedule.json') +const receipt = { ...lastDelivery, targetSessionId: sessionId } +const historyRequest = { request: { id, limit: 20 } } +const expectedHistory = { + ok: true, + value: { + id, records: [{ ...receipt, prompt: record.prompt }], earlierRecordsUnavailable: false, + earlierRecordsPruned: false, retention: { days: 30, records: 200 }, + }, } -function encoded(value: unknown): string { - return `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: value } } }, null, 2)}\n` +/** Catalog entry retains the stored daily rule and has no invented creation or change times. */ +const retainedEntry = (title: string) => expect.objectContaining({ + id, title, prompt: record.prompt, scheduledAt: record.scheduledAt, target: { kind: 'session', sessionId }, + involvedSessionIds: [sessionId], status: 'active', lastDelivery: receipt, + kind: 'daily', time: record.time, timeZone: record.timeZone, +}) as object + +/** Seed the existing domain or the exact bytes a preceding Host edited. */ +async function seed(root: string, bytes = source): Promise { + await mkdir(join(root, 'home', 'storages'), { recursive: true }) + await writeFile(sourcePath(root), bytes) +} + +async function readStore(root: string): Promise { + return readFile(sourcePath(root), 'utf8') } async function connect(url: string, signal: AbortSignal) { @@ -48,71 +70,84 @@ async function connect(url: string, signal: AbortSignal) { } } -it('edits a retained task through built Remote bindings and preserves its identity and seeded history after cold startup', async (test) => { - let persisted = encoded(task) - let updated = record - const historyRequest = { request: { sessionId, id, limit: 20 } } - const expectedHistory = { - ok: true, - value: { - id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false, - earlierRecordsPruned: false, retention: { days: 30, records: 200 }, - }, - } +it('edits an existing task through built Remote bindings and keeps its identity and seeded history after cold startup', async (test) => { + let persisted: string | undefined + let renamed: ScheduleCatalogEntry | undefined await withDefaultWeb(test, async ({ root, url }) => { const rpc = await connect(url, test.signal) - expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...record, sessionId, status: 'active', lastDelivery }] }) + const catalog = await rpc('schedule/catalog', {}) + expect(catalog).toEqual({ ok: true, value: [retainedEntry(record.title)] }) expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) - const target = new Date(Date.now() + 12 * 60 * 60 * 1_000) - target.setUTCMilliseconds(125) - updated = { ...record, time: target.toISOString().slice(11, 23), timeZone: 'UTC', scheduledAt: target.toISOString() } - const change = { kind: 'daily', daily: { time: updated.time, time_zone: 'UTC' } } - expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } })) - .toEqual({ ok: true, value: { id, updated: true, record: updated } }) - persisted = await readFile(taskPath(root), 'utf8') - expect(JSON.parse(persisted)).toEqual({ - unit: { name: 'schedule', version: 1 }, - global: null, - tables: { tasks: { [id]: { ...task, record: updated } } }, + const { value: [retained] } = catalog as { value: ScheduleCatalogEntry[] } + const rename = { + request: { id, action: 'update', patch: { title: 'Renamed instruction' }, expectedUpdatedAt: retained?.updatedAt ?? null }, + } + const edited = await rpc('schedule/edit', rename) + expect(edited).toEqual({ ok: true, value: retainedEntry('Renamed instruction') }) + renamed = (edited as { value: ScheduleCatalogEntry }).value + persisted = await readStore(root) + const document = storedUnit(persisted) + expect(document.unit).toEqual({ name: 'schedule', version: 2 }) + expect(document.global).toBeNull() + expect(document.tables.tasks[id]!).toMatchObject({ record: { ...record, title: 'Renamed instruction' }, + target: { kind: 'session', sessionId }, status: 'active', updatedAt: renamed.updatedAt, lastDelivery: receipt }) + expect(document.tables.tasks[id]!).not.toHaveProperty('sessionId') + expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) + + expect(await rpc('schedule/edit', rename)).toEqual({ ok: true, value: { id, code: 'schedule_conflict' } }) + expect(await rpc('schedule/edit', { request: { id: 'missing-task', action: 'delete' } })) + .toEqual({ ok: true, value: { id: 'missing-task', code: 'schedule_not_found' } }) + expect(await readStore(root)).toEqual(persisted) + }, { prepare: root => seed(root) }) + + await withDefaultWeb(test, async ({ root, url }) => { + const rpc = await connect(url, test.signal) + expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [renamed] }) + expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) + expect(await readStore(root)).toEqual(persisted) + await rpc('session/create', { request: { sessionId, cwd: root } }) + const rrule = 'DTSTART;TZID=Asia/Shanghai:20990101T090000\nRRULE:FREQ=DAILY' + const replaced = await rpc('schedule/edit', { + request: { id, action: 'update', patch: { timing: { kind: 'rrule', rrule } }, expectedUpdatedAt: renamed?.updatedAt }, }) + expect(replaced).toEqual({ + ok: true, value: expect.objectContaining({ id, title: 'Renamed instruction', scheduledAt: '2099-01-01T01:00:00.000Z' }) as object, + }) + const stored = storedUnit(await readStore(root)).tables.tasks[id]! + expect(stored.record).toMatchObject({ kind: 'rrule', rrule }) + expect(stored).not.toHaveProperty('sessionId') + expect(stored).toMatchObject({ target: { kind: 'session', sessionId }, lastDelivery: receipt }) expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) - - expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } })) - .toEqual({ ok: true, value: { id, updated: false, code: 'schedule_conflict' } }) - expect(await rpc('schedule/update', { request: { sessionId: 'other-owner', id, expected: updated, change } })) - .toEqual({ ok: true, value: { id, updated: false, code: 'schedule_not_found' } }) - expect(await readFile(taskPath(root), 'utf8')).toBe(persisted) - }, { prepare: root => seed(root, persisted) }) - - await withDefaultWeb(test, async ({ root, url }) => { - const rpc = await connect(url, test.signal) - expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...updated, sessionId, status: 'active', lastDelivery }] }) - expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) - expect(await rpc('schedule/update', { request: { - sessionId, id, expected: updated, change: { kind: 'daily', daily: { time: updated.time, time_zone: 'Etc/UTC' } }, - } })).toEqual({ ok: true, value: { id, updated: false, record: updated } }) - expect(await readFile(taskPath(root), 'utf8')).toBe(persisted) }, { prepare: root => seed(root, persisted) }) }) -it('creates and idempotently adopts the explicit Session id bound to seeded task data without touching its task file', async (test) => { - const persisted = encoded(task) - const expectedList = { ok: true, value: [record] } - const expectedHistory = { - ok: true, - value: { - id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false, - earlierRecordsPruned: false, retention: { days: 30, records: 200 }, - }, - } +it('creates and idempotently adopts the explicit Session id bound to existing task data without touching its task files', async (test) => { await withDefaultWeb(test, async ({ root, url }) => { const rpc = await connect(url, test.signal) + // A Schedule request waits for startup to validate the task store. + const view = await rpc('schedule/view', { request: { id } }) + expect(view).toEqual({ ok: true, value: retainedEntry(record.title) }) + const retained = await readStore(root) const create = { request: { sessionId, cwd: root } } const expectedCreate = { ok: true, value: { sessionId, agentPreset: 'standard' } } expect(await rpc('session/create', create)).toEqual(expectedCreate) expect(await rpc('session/create', create)).toEqual(expectedCreate) - expect(await rpc('schedule/list', { request: { sessionId } })).toEqual(expectedList) - expect(await rpc('schedule/history', { request: { sessionId, id, limit: 20 } })).toEqual(expectedHistory) - expect(await readFile(taskPath(root), 'utf8')).toBe(persisted) - }, { prepare: root => seed(root, persisted) }) + expect(await rpc('schedule/view', { request: { id } })).toEqual(view) + expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory) + expect(await readStore(root)).toEqual(retained) + expect(await readFile(sourcePath(root), 'utf8')).toBe(source) + }, { prepare: root => seed(root) }) }) + +/** Read test-owned output without normalizing away the fields asserted below. */ +function storedUnit(text: string): { + readonly unit: unknown + readonly global: unknown + readonly tables: { readonly tasks: Record } +} { + return JSON.parse(text) as { + unit: unknown + global: unknown + tables: { tasks: Record } + } +} diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 7563a323aa..687afcb6d8 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -7,7 +7,7 @@ import { modulePackage } from './runtime-roster.ts' import { experimentalRuntimeReferences } from './runtime-roster-isolation.ts' import { withDefaultWeb, webGet } from './default-web-process.ts' -const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' +const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-voice-input' it('boots default Web with the shipped Schedule rows, without experimental modules, time context, or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { @@ -37,6 +37,9 @@ it('boots default Web with the shipped Schedule rows, without experimental modul // The shipped composition mounts the Host Schedule service and its task // page; the clock stays preset-level, so neither plane carries a row for it. expect(roster.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: '@deepseek-ai/dsh-agent-team', state: FiberState.ACTIVE }), + expect.objectContaining({ name: '@deepseek-ai/dsh-tool-agent-team', state: FiberState.ACTIVE }), + expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-agent-team', state: FiberState.ACTIVE }), expect.objectContaining({ name: '@deepseek-ai/dsh-schedule', state: FiberState.ACTIVE }), expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-schedule', state: FiberState.ACTIVE }), ])) @@ -50,7 +53,7 @@ it('boots default Web with the shipped Schedule rows, without experimental modul const mounted = contaminated.plugins.filter(plugin => plugin.modules.some(url => modulePackage(url) === experimentalName)) expect(mounted).toEqual([expect.objectContaining({ state: FiberState.ACTIVE })]) expect(experimentalRuntimeReferences(contaminated)).toEqual(expect.arrayContaining([ - expect.stringContaining('/packages/experimental/client-ui-agent-team/'), + expect.stringContaining('/packages/experimental/client-ui-voice-input/'), ])) }) }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index baac502987..ff5fd57cae 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -248,9 +248,9 @@ beforeAll(async () => { }, 120_000) describe('the shipped Web composition', () => { - it('exposes only the directory tool in the global layer', () => { - // Directory selection is global; action tools belong to their Agent presets. - expect(toolNames(ctx)).toEqual(['working_directory']) + it('registers the directory and conditional Team tools in the global layer', () => { + // Team registrations are shared; session mode controls their step-time availability. + expect(toolNames(ctx)).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) }) it('keeps the token meter and its context-meter projections on the host plane', async () => { @@ -302,9 +302,9 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', - 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', + 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'send_message', 'skill', - 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', + 'subagent', 'subagent_fork', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'working_directory', 'write', ]) expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() @@ -408,7 +408,7 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. @@ -485,7 +485,7 @@ describe('the shipped Web composition', () => { const last = adapter.requests.at(-1) expect(last?.messages.flatMap(message => message.content) .some(block => block.type === 'text' && block.text.includes('Preserve the requested project task.'))).toBe(true) - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -529,7 +529,7 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) expect(ctx.agentPresets.serviceFor(minimal.agent, 'compaction')) .not.toBe(ctx.agentPresets.serviceFor(full.agent, 'compaction')) @@ -538,7 +538,7 @@ describe('the shipped Web composition', () => { // Tearing the minimal session down leaves the full one whole. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) - expect(toolNames(ctx)).toEqual(['working_directory']) + expect(toolNames(ctx)).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await full.dispose() } @@ -697,7 +697,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -790,7 +790,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac const productCtx = await bootSelected(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort()) + expect(products.filter(name => productCtx.subagents.getProvider(name) !== undefined).sort()) .toEqual([...installed].sort()) const expected = installed.map(toolName).sort() for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { @@ -881,6 +881,8 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. + const spills = vi.spyOn(copyCtx.spillStore, 'saveText') + onTestFinished(() => { spills.mockRestore() }) const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', @@ -889,11 +891,8 @@ describe('a user preset declared from the shipped cordis rows', () => { agent: copied.agent, }) expect(queried.isError).toBe(false) - // The whole-table answer can pass the inline token budget once the - // preset's tool table grows, and the spill policy then replaces its - // middle with a gap plus a spill footer. What this case proves is the - // provider's answer, so assert the names it must carry. - const queriedText = resultText(queried) + // The tool catalog may exceed the inline budget; assert its complete stored text. + const queriedText = spills.mock.calls.at(-1)?.[0].content ?? resultText(queried) for (const toolName of ['bash', 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']) { expect(queriedText).toContain(`"name": "${toolName}"`) } @@ -1004,7 +1003,7 @@ describe('a forked session', () => { }) describe('a delegated child', () => { - it.each([false, true])('inherits preset tools with seeded=%s and refuses child reminders', async (seeded) => { + it.each([false, true])('inherits preset tools with seeded=%s and refuses child scheduled tasks', async (seeded) => { const parent = await ctx.agents.create({ sessionId: SessionId(`preset-child-parent-${seeded}`), meta: { agentPreset: 'standard' }, @@ -1026,10 +1025,12 @@ describe('a delegated child', () => { expect(toolNames(ctx, child.agent)).toContain('bash') expect(ctx.tools.schemas(child.agent)).toEqual(ctx.tools.schemas(parent.agent)) const calls = [ - ['schedule_create', { prompt: 'Check', after_seconds: 60, title: 'Check' }], + ['schedule_create', { + title: 'Check', prompt: 'Check', rrule: 'DTSTART:20990101T010000Z\nRRULE:FREQ=DAILY;COUNT=1', target: 'this_session', + }], ['schedule_list', {}], - ['schedule_delete', { id: 'task-1' }], - ['schedule_update', { id: 'task-1', title: 'Check' }], + ['schedule_view', { id: 'task-1' }], + ['schedule_edit', { id: 'task-1', action: 'update', title: 'Check' }], ] as const for (const [name, args] of calls) { expect(toolNames(ctx, child.agent)).toContain(name) @@ -1037,7 +1038,7 @@ describe('a delegated child', () => { callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, name, arguments: args, agent: child.agent, }) - expect(result.value).toEqual({ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }) + expect(result.value).toEqual({ code: 'subagent_session', message: 'A delegated subagent cannot use scheduled tasks.' }) } expect(child.agent.session.header.agentPreset).toBe('standard') } finally { @@ -1086,7 +1087,7 @@ describe('the default preset as a user setting', () => { }) try { // An omitted preset id resolves the stored default instead of the composed default. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -1132,7 +1133,7 @@ describe('a profile patch stored before Developer tools owned preset selection', setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined), }) try { - expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } diff --git a/apps/cli/tsdown.config.ts b/apps/cli/tsdown.config.ts index e85aadc6e8..11437c24af 100644 --- a/apps/cli/tsdown.config.ts +++ b/apps/cli/tsdown.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsdown' * Declarations come from `tsc -b` (dts: false), matching every package. */ export default defineConfig({ - entry: ['lib/types/bin.js', 'lib/types/profile-boot.js'], + entry: { bin: 'lib/types/installed-bin.js', 'profile-boot': 'lib/types/profile-boot.js' }, outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 93469b15f2..9e4d9cc0f4 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index e7fced6b8e..081d44f9f8 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -15,8 +15,8 @@ en: 127ea90553cb6af7 zh: 7b1c5aec8558c684 /deepseek-harness-desktop/application-overview: - en: b57525e17b9638ab - zh: 274fe8344f7c2202 + en: d4afa46403997884 + zh: 9b9fe126afda70f0 /deepseek-harness-desktop/terminal-command: en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 @@ -60,14 +60,14 @@ en: 67bf0a18faa7aa76 zh: 782e14ce62bb939f /deepseek-harness-desktop/updates/local-desktop-settings: - en: 2e57457c44c85b5f - zh: 5b8510a3dc4c6c9d + en: 01461be5b81e8609 + zh: 58e818df821a3aa2 /deepseek-harness-desktop/updates/mandatory-update-policy: en: 182a9c6705213e13 zh: 681762f83490f4c6 /deepseek-harness-desktop/updates/local-updater-qualification: - en: 68ec2f8bd5763ea6 - zh: 23b932dfe8503602 + en: db5e898140a73d53 + zh: 80a9d58c74b4bb24 /deepseek-harness-desktop/low-level-development-overrides: en: 8264b135871e8978 zh: 0939aafb1b2961fc diff --git a/apps/desktop/README.md b/apps/desktop/README.md index f961a72a92..335991f722 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -35,7 +35,7 @@ Electron caches the last confirmed UI language in `userData/desktop/locale-cache Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. -The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. +The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. Native Host requests send the current Host's authentication cookie explicitly, and no Electron session stores it; each Host start removes `dsh-auth-*` cookies that earlier releases left in the default session, because cookies ignore ports and would otherwise accompany every request to `127.0.0.1`. Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme. @@ -53,7 +53,7 @@ A server-expired account credential returns to Welcome when no official API key Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization, a packaged usage description, and `com.apple.security.device.audio-input` in the main application and Helper signatures. -Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools. +Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. The application menu shows a localized “Developer tools” command in a separate group before Hide and Quit (before Exit on Windows). Windows exposes it in the titlebar application popup; its hidden native application menu registers the shortcuts. macOS and Linux keep a hidden F12 shortcut alias in their application menus. Update overlays and packaged embedded browser guests disable DevTools. @@ -302,7 +302,17 @@ On first launch, including after an upgrade, Desktop creates this document if it } ``` -Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). +Startup loading never overwrites existing documents; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules). + +In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save. + +During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends update operations; without a mandatory policy block, the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced; its configuration-error action opens the same file-location and reload dialog. Successful channel changes trigger a fresh mandatory-policy check, waiting for an older channel’s in-flight check if necessary. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation. + +The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback. + +Once installation eligibility is revoked, obsolete-file removal failures do not block switching. They are flushed to `update-cleanup.jsonl` in Electron’s logs directory with the time, operation, path and filesystem error; inability to write that log falls back to stderr. Later download failures are reported independently and are not attributed to an earlier cleanup warning. Structured filesystem errors distinguish insufficient space, denied writes and busy files; the error dialog retains the current error code and supplied file paths in its details. Errors without structured evidence keep the generic download-failure guidance. + +Updater adapter compatibility and platform version records are documented in [Electron updater compatibility](tests/updates/electron-updater/README.md). @@ -353,6 +363,8 @@ The command builds the Desktop shell and runs its coordinator with real Electron The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged. +Native original-file operations accept only the trusted application frame. Completion returns the destination path to that frame; revealing it uses the existing Host path-opening API without a separate reveal IPC or saved-file history. Downloads use Chromium’s URL download and native save-dialog APIs; `DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` bounds startup until Chromium creates the download (300000 ms); the native save dialog has no application deadline. Clipboard-only remote image reads omit credentials, validate every redirect, reject the application Host and its loopback aliases, stop on main-frame navigation, and use `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS` (30000 ms) and `DSH_DESKTOP_IMAGE_READ_MAX_BYTES` (67108864 bytes). Image copying uses the renderer Clipboard API after PNG conversion. Other reachable HTTP(S) destinations follow deployment network isolation. [File actions](../../packages/client/ui-file-actions/README.md) defines saving and clipboard behavior. + ## Low-level development overrides diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a7500d5d28..82ccf994a4 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -35,7 +35,7 @@ Electron 在 `userData/desktop/locale-cache` 缓存最近确认的界面语言 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 -桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 +桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。原生 Host 请求会显式发送当前 Host 的认证 Cookie,任何 Electron session 都不保存该 Cookie;每次启动 Host 时都会移除早期版本遗留在默认 session 中的 `dsh-auth-*` Cookie,因为 Cookie 不区分端口,否则这些 Cookie 会随每个发往 `127.0.0.1` 的请求一并发送。 Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。 @@ -53,7 +53,7 @@ Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权、随包用途说明,以及主应用与 Helper 签名中的 `com.apple.security.device.audio-input` 权限。 -按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。这些原生快捷键通过隐藏的应用菜单项注册。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 +按 F12(多媒体功能键键盘上为 Fn+F12)、macOS 的 Command+Option+I 或 Windows 的 Ctrl+Shift+I,可切换当前获得焦点的应用页面的 DevTools,打包版本同样支持。应用菜单在隐藏和退出命令之前的独立分组中显示本地化的“开发者工具”命令。Windows 在标题栏的应用弹出菜单中显示开关,通过隐藏的原生应用菜单注册快捷键;macOS 和 Linux 的应用菜单保留隐藏的 F12 快捷键别名。更新遮罩和打包版本的内嵌浏览器禁用 DevTools。 @@ -302,7 +302,17 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett } ``` -已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 +启动读取不会覆盖已有文件;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。 + +在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。 + +UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停更新操作;没有强更策略阻塞时,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效;强更页面的配置异常入口可打开同一文件定位和重读对话框。渠道切换成功后立即重新检查强更策略;如旧渠道的检查仍在进行,则等待其完成后检查新渠道。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。 + +共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。 + +撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。有结构化文件系统错误时,区分空间不足、无法写入和文件占用;错误弹窗详情保留当次错误码及已提供的文件路径。没有结构化依据时保留通用下载失败提示。 + +更新适配层兼容性和平台版本记录见 [Electron updater 兼容性](tests/updates/electron-updater/README.zh.md)。 @@ -353,6 +363,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd 下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。 +原生原始文件操作仅接受可信应用页面。完成后向该页面返回目标路径;显示位置复用现有 Host 路径打开 API,不提供独立 reveal IPC,也不保留已保存文件历史。下载使用 Chromium URL 下载和原生保存对话框 API;`DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` 限定 Chromium 创建下载前的启动期限(300000 毫秒),不限制原生保存对话框的停留时间。仅剪贴板使用的远程图片读取不携带凭据,逐次校验重定向,拒绝应用 Host 及其回环别名,在主框架导航时终止,并使用 `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS`(30000 毫秒)与 `DSH_DESKTOP_IMAGE_READ_MAX_BYTES`(67108864 字节)。图片复制在转换为 PNG 后使用渲染进程的 Clipboard API。其他可达的 HTTP(S) 目标遵循部署侧网络隔离。[文件操作](../../packages/client/ui-file-actions/README.zh.md) 定义保存和剪贴板行为。 + ## 底层开发覆盖项 diff --git a/apps/desktop/config/publishing/macos/production.json.example b/apps/desktop/config/publishing/macos/production.json.example new file mode 100644 index 0000000000..cb7f67a0a4 --- /dev/null +++ b/apps/desktop/config/publishing/macos/production.json.example @@ -0,0 +1,13 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download.deepseek.com", + "credentialsFile": "/absolute/path/to/packaging/secrets/cos-production.json", + "macOS": { + "signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY", + "teamId": "XXXXXXXXXX" + }, + "githubApp": { + "clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID", + "privateKeyFile": "/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem" + } +} diff --git a/apps/desktop/config/publishing/macos/test.json.example b/apps/desktop/config/publishing/macos/test.json.example new file mode 100644 index 0000000000..6981a88c6b --- /dev/null +++ b/apps/desktop/config/publishing/macos/test.json.example @@ -0,0 +1,9 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download-test.example.com", + "credentialsFile": "/absolute/path/to/packaging/secrets/cos-test.json", + "macOS": { + "signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY", + "teamId": "XXXXXXXXXX" + } +} diff --git a/apps/desktop/config/publishing/windows/production.json.example b/apps/desktop/config/publishing/windows/production.json.example new file mode 100644 index 0000000000..9a2a743227 --- /dev/null +++ b/apps/desktop/config/publishing/windows/production.json.example @@ -0,0 +1,12 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download.deepseek.com", + "credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-production.json", + "windows": { + "certificateThumbprint": "0000000000000000000000000000000000000000" + }, + "githubApp": { + "clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID", + "privateKeyFile": "C:/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem" + } +} diff --git a/apps/desktop/config/publishing/windows/test.json.example b/apps/desktop/config/publishing/windows/test.json.example new file mode 100644 index 0000000000..cf3e1a40a1 --- /dev/null +++ b/apps/desktop/config/publishing/windows/test.json.example @@ -0,0 +1,8 @@ +{ + "bucket": "replace-with-bucket-1250000000", + "origin": "https://download-test.example.com", + "credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-test.json", + "windows": { + "certificateThumbprint": "0000000000000000000000000000000000000000" + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 081a90ead3..3f95b9aa8b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.2.2-alpha.2", + "version": "0.2.2-alpha.3", "private": true, "license": "MIT", "author": { @@ -15,6 +15,7 @@ "bundle": "tsdown --config-loader native", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", + "test:updates:channels": "pnpm run build && node scripts/test-update-channels.mjs", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "render:tray-icon": "tsx scripts/render-tray-icon.ts", @@ -88,6 +89,7 @@ "@deepseek-ai/dsh-plugin-manager": "workspace:*", "zod": "4.4.3", "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-actions": "workspace:*", "tencentcloud-sdk-nodejs-teo": "4.1.330" } } diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index b98c3d075f..5015126b05 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,26 +18,26 @@ en: 0b35c7471c09b27a zh: cc4a382b99fc3210 /desktop-packaging/release-versions: - en: 3f38ab26ee195b39 - zh: f00eddafa8f3b9ca + en: 8cc17b1e5ae3e422 + zh: 7de3e4f1e76be3e3 /desktop-packaging/configuration-and-local-builds: en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: 9dd2227f2878519e - zh: cd95dd6efd48298f + en: e0a852fdda949c40 + zh: b96c613022f6189e /desktop-packaging/macos-self-hosted-packaging: - en: ee628e830d849ae9 - zh: 44313f5fb842f042 + en: 239d5ab433020745 + zh: e8916ba0f4682ae1 /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 /desktop-packaging/local-macos-dmg-without-release-signing: - en: 2feaa0412247d81f - zh: 461dde8e13fd4734 + en: 1146bbc11be43960 + zh: 7c13543a0513c059 /desktop-packaging/macos-release-signing-and-diagnostics: - en: 29fa6bd8919f3ec0 - zh: 9e31e037fa3d9378 + en: a0b4aa1080ecf6fb + zh: 37d4b8934f89bee8 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e @@ -45,11 +45,11 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 83e1075417039410 - zh: 3088a7ae0a2cc3c5 + en: 44044a5c14b75533 + zh: 39d91a369c4ec2fa /desktop-packaging/desktop-runtime-preparation: - en: ab73b67d899e611d - zh: d77b0525c6b81e91 + en: 84be38ab3136fff7 + zh: bb4ed12ee0e1dfa0 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 83a0e5a77b..b5875363ae 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). @@ -113,13 +113,13 @@ The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workf Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. -Open the completed run in Actions and download its Artifacts. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. +All Windows packaging modes, including test and unsigned, require the repository variable `DESKTOP_PUBLISH_RUNNERS`; configure the [original-machine route](../publishing/ci/README.md#machine-setup) before dispatch. -For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package--/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing. +Open the completed run in Actions and download its Artifacts from the independent `upload-artifact` job. Installer names use the original `package_run_attempt`; diagnostic names use the current `run_attempt`. If archival fails after packaging succeeds, rerun only `upload-artifact` to reuse the retained installers. `windows-x64-signed---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication. For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory. -The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. +The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. The priority helper is copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. A missing helper stops CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats. Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression. @@ -127,15 +127,16 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. +The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. -All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields. +Both macOS workflows require the repository variable `DESKTOP_PUBLISH_RUNNERS`, including test and unnotarized modes. Their independent `upload-artifact` job retains installer names with the original build attempt; if archival fails after packaging succeeds, rerun only that job. See [original-machine routing and recovery](../publishing/ci/README.md#recovery-and-records). + +All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). - ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. Install the user service from the checkout as the same account that registered the runner, without `sudo`: @@ -175,19 +176,21 @@ pnpm run package:desktop:mac:x64:unsigned Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. -These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. +These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. These builds stage no Computer Use helper, so Computer Use reports the missing helper when it prepares. ## macOS release signing and diagnostics -The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file outside the notarized Computer Use helper bundles; for those bundles it verifies the deep signature, release identity, stapled ticket, and Gatekeeper acceptance without re-signing their files. A signed local build of a source that ships Computer Use therefore needs the notarized helper staged first with `scripts/cua-helper/package.ts` and `stage.ts`; an ad-hoc `build:native` output under `native/bin` fails this verification; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. -Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. +Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted, and environments are not recorded. Apple command failures retain their exit code, signal, stdout and stderr in redacted form; the adapter also writes these details to stderr when no journal is available. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. + +CUA helper packaging prints `CUA_PACKAGING_RECORD` and creates its own record in the same directory before desktop installer startup. Its `run.json` identifies the helper target, product version and source commit; `events.jsonl` retains command and notarization phases, submission IDs and Apple results. `result.json` records success or failure. These records survive temporary payload cleanup, and the Desktop Actions diagnostic upload includes them even when helper preparation fails. App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. @@ -249,7 +252,7 @@ Desktop packs workspace packages locally and installs external dependencies thro The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. -The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). +The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. @@ -283,7 +286,7 @@ Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files, and verify the notarized Computer Use helper bundles without re-signing them, before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 3868508c9e..eac441d256 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 @@ -113,13 +113,13 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 -在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +所有 Windows 打包模式(含 test 和 unsigned)都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`;触发前配置[原机器路由](../publishing/ci/README.zh.md#machine-setup)。 -测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 +在 Actions 打开完成的运行,下载独立 `upload-artifact` job 生成的 Artifacts。安装包名称使用原构建的 `package_run_attempt`,诊断名称使用当前 `run_attempt`。打包成功但归档失败时,仅重跑 `upload-artifact` 以复用保留的安装包。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。 -依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 +依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。 安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。 @@ -127,15 +127,16 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 -三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。 +两个 macOS 工作流都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`,包括 test 和未公证模式。独立 `upload-artifact` job 的安装包名称保留原构建 attempt;打包成功但归档失败时,仅重跑该 job。参见[原机器路由与恢复](../publishing/ci/README.zh.md#recovery-and-records)。 + +三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 - 无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: @@ -175,19 +176,21 @@ pnpm run package:desktop:mac:x64:unsigned 添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 -这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 +这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。这类构建不放入 Computer Use helper,Computer Use 准备时会报告 helper 缺失。 ## macOS 发布签名与诊断 -macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到已公证 Computer Use helper 应用包之外的每个内嵌 Mach-O 文件;对这些应用包,则校验深度签名、发布身份、钉票和 Gatekeeper 评估结果,不重签其中的文件。因此,打包会随附 Computer Use 的源码时,本地签名构建需要先用 `scripts/cua-helper/package.ts` 和 `stage.ts` 放入已公证的 helper;`native/bin` 下由 `build:native` 生成的临时签名产物无法通过此校验;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 -Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 +Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖,不记录环境变量全集。Apple 命令失败时保留脱敏后的退出码、信号、stdout 和 stderr;没有日志目录时,适配器也会将这些详情写入 stderr。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 + +CUA helper 打包通过 `CUA_PACKAGING_RECORD` 输出同一目录下的独立记录路径,在桌面安装包开始构建前创建。其 `run.json` 记录 helper 目标、产品版本和源码提交,`events.jsonl` 保留命令与公证阶段、已返回的提交 ID 和 Apple 处理结果,`result.json` 记录成功或失败。这些记录不会随临时 payload 清理而删除;即使 helper 准备失败,Desktop Actions 的诊断上传步骤也会收集它们。 App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 @@ -249,7 +252,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安 [Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 -[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 +[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 @@ -283,7 +286,7 @@ Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补 Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,并在不重签的前提下校验已公证的 Computer Use helper 应用包,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 99b4b44aef..fb30359d5f 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -21,11 +21,11 @@ en: eed7c4ed0d4e2758 zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: - en: 3c3e3a09573c4386 - zh: c851ae6a94f2284a + en: 4723059d6f16b25b + zh: 54e5afad8fff8eb6 /desktop-publication/fixed-installer-downloads: - en: ce8296f8f1ee881e - zh: 749ab63bd6ed4248 + en: ed4dcced7bd4e7ba + zh: a9e7f5da9e39454f /desktop-publication/upload-credentials: en: 47442fb912fa4d5d zh: a21a0d67cd01724b diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index b9bb98af2b..085833b746 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -59,9 +59,9 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching. +The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Deployment and update channel are independent; see [package channel selection](../packaging/README.md#release-versions). -Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. +Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Each package publishes only its selected channel: `nightly.yml` / `nightly-mac.yml` or `stable.yml` / `stable-mac.yml`. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification. Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field. @@ -69,11 +69,13 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the ## Fixed installer downloads +Uploads set `Content-Disposition: attachment` with the verified installer basename as `filename`, preserving the complete version, platform and architecture. The fixed `dsh-latest-*` URL remains independent of nightly/stable update channels; no additional naming suffix is added. Verify CDN header forwarding and the browser save name after publication. + Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically. -The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest ` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh. +The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest ` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The [CI publication workflow](ci/README.md#recovery-and-records) has separate website upload and observed-purge jobs. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 0f64fb31ee..4c306678b9 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -59,9 +59,9 @@ pnpm run package:desktop:mac:arm64 pnpm run upload:mac:arm64 ``` -内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。 +内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。部署与更新渠道相互独立;参见[安装包渠道选择](../packaging/README.zh.md#release-versions)。 -前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 +前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个包仅发布自身选定渠道的清单:`nightly.yml` / `nightly-mac.yml` 或 `stable.yml` / `stable-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。 Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。 @@ -69,11 +69,13 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo ## 固定安装包下载 +上传设置 `Content-Disposition: attachment`,`filename` 沿用经校验安装包的 basename,保留完整版本、平台和架构。固定 `dsh-latest-*` URL 与 nightly/stable 升级通道独立,不另加命名后缀。发布后需验证 CDN 响应头透传及浏览器保存文件名。 + 固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。 -命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest ` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。 +命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest ` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。[CI 发布工作流](ci/README.zh.md#recovery-and-records)使用独立的官网上传和刷新观察 job。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index d6940d4d14..6764ae324f 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -21,14 +21,14 @@ en: 0478d8e35f9a3d55 zh: 0aa3fd828bc02691 /desktop-ci-publication/machine-setup: - en: 6e7b928af17f4377 - zh: 7c559078306df3fd + en: 0f43f2e193e95043 + zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: f7c12d343891e547 - zh: da596ee1366c2046 + en: 3f58698708671202 + zh: be0ba215a67168d6 /desktop-ci-publication/recovery-and-records: - en: 2e1e6b16d0d6bd41 - zh: 6ff4ad5a8f1f57b4 + en: 266afba2adeeadef + zh: 063114103585dae8 /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 02f1e1b6af..2232ea39c7 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -54,7 +54,9 @@ For a queued job, compare the job page's requested labels with **Settings → Ac Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases///`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published. -Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. +Start from the [macOS test](../../config/publishing/macos/test.json.example), [macOS production](../../config/publishing/macos/production.json.example), [Windows test](../../config/publishing/windows/test.json.example), or [Windows production](../../config/publishing/windows/production.json.example) template. Create the `publishing/` directory and copy the selected file to `publishing/.json` below `DSH_PACKAGING_CONFIG_DIR`. Replace the example bucket, test origin, absolute paths, macOS signing identity and team ID or Windows certificate thumbprint, and production App client ID with the machine settings; keep the fixed production origin. Templates contain no credentials and do not grant COS, GitHub, or signing permissions. Store referenced secrets outside Git with access restricted to the publishing account. macOS uses the same configuration for arm64 and x64; the retained package determines the target. + +Each configuration declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. @@ -66,11 +68,11 @@ Environment approval gates this workflow, not arbitrary code running under the s ## Package and publish -For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. +For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. An archive-only rerun retains the original build attempt in artifact names, so this early check can miss it; local manifest qualification still rejects debugging builds before publication. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. -Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. +Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt and its successful package job. An archival-only retry retains the original build attempt; a failed newer package job never falls back to an older success. An explicit attempt selects that build even when its separate GitHub archival failed. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. @@ -78,11 +80,21 @@ Before installing dependencies on the original runner, preparation and the appro ## Recovery and records -After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. +The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. If an archival retry stays queued without a runner, inspect the resolved runner label and upstream package outputs before treating the runner as offline. Missing outputs require operator recovery of the original retained directory and manifest; rerunning the whole workflow rebuilds the package and is not archival-only recovery. Verify output reuse in the Actions acceptance test. + +Production COS publication enables website installer upload and cache refresh by default (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and stable packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The pre-approval summary names the fixed website URL, selected version and lack of downgrade protection when website upload is selected. The existing version/update publication retains its own checks. + +Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Version publication, website upload and cache refresh each use the production approval Environment; later jobs and reruns may require another approval. GitHub concurrency retains at most one pending job per group, so a newer pending job can cancel an older pending job; inspect canceled jobs before rerunning them. + +Website state lives in `upload-records/website--/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. Website URLs and download filenames follow the [fixed-download rules](../README.md#fixed-downloads). Upload retries reconcile both bytes and `Content-Disposition`; identical bytes with a missing or different header receive one upload and readback. A completed journal with different remote bytes stops recovery and requires a newly approved publication. Completion evidence survives failed metadata repairs. Each new upload intent clears the previous purge record, so repaired metadata requires a new cache refresh; inspect EdgeOne history for the previous task ID and diagnostics. Unknown writes are read before any retry; CDN header forwarding requires real-environment validation. Rerun only the purge job to inspect its saved task when the original routing and preparation outputs remain available. With website upload enabled, website jobs skip only an explicitly selected test deployment. Each job requires its injected `route` or `selection` output for local preflight. Missing or changed website approval fails before upload or purge submission. Missing `website-destination` broadens both website concurrency groups. Missing `runner` output can prevent scheduling before preflight. Stop partial retries when outputs are missing; the workflow does not reconstruct them. Before restarting the publication workflow, inspect current remote state and obtain approval for its upload scope; this is not cache-only recovery. Do not rerun packaging to recover publication outputs. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and a fresh publication run is one recovery option. Operators may instead use the documented [fixed-download purge command](../README.md#fixed-downloads); Windows operators use the existing DPAPI credential launcher described there. This submits only a fixed-URL purge: it does not observe completion or update this Actions run’s journal. Inspect the returned task in EdgeOne before considering recovery complete. + +Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; release operators must verify production COS/EdgeOne permissions, job-id filtering, time-range queries, task type and timestamp formats, cross-attempt Actions recovery, and actual CDN behavior before relying on recovery in production. + +After resolving a failure, re-run the failed publishing job only when the upstream outputs required above remain available, keeping its prepared package attempt; otherwise stop partial retries. A new publication can explicitly select the original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, stable writes `stable.yml` / `stable-mac.yml`. Stable-channel binaries use a `stable/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. -If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. +If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. A rejected tag creation retains its HTTP status and validated GitHub request ID; a follow-up lookup does not replace that failure with a missing-tag error. A repository-rule rejection directs the operator to the matching rulesets without logging the raw response. Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 3a635b5116..99d245bf1a 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -54,7 +54,9 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions 保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases///`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。 -在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 +从 [macOS test](../../config/publishing/macos/test.json.example)、[macOS production](../../config/publishing/macos/production.json.example)、[Windows test](../../config/publishing/windows/test.json.example) 或 [Windows production](../../config/publishing/windows/production.json.example) 模板开始,创建 `publishing/` 目录,将所选文件复制到 `DSH_PACKAGING_CONFIG_DIR` 下的 `publishing/.json`。用机器实际配置替换示例 bucket、test origin、绝对路径、macOS 签名身份和 Team ID 或 Windows 证书指纹,以及 production App client ID;保留固定的 production origin。模板不含凭据,也不授予 COS、GitHub 或签名权限。引用的秘密文件保存在 Git 之外,只允许发布账户访问。macOS 的 arm64 和 x64 共用配置,目标由留存包决定。 + +每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 @@ -66,11 +68,11 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 打包与发布 -发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 +发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。仅重跑归档时,artifact 名称保留原构建 attempt,早期检查可能未识别该包;本地清单资格校验仍会在发布前拒绝调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 -打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 +打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试及其成功的 package job;归档重跑继续使用原始构建 attempt,新的构建失败时不回退到较早成功构建。显式填写则选择对应构建,独立的 GitHub 归档失败不取消其资格。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 @@ -78,11 +80,21 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 -解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 +打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。归档重跑若一直排队且未分配 runner,应先检查解析出的 runner 标签和上游 package 输出,不能直接认定机器离线。输出缺失时需由操作者从原留存目录和 manifest 恢复文件;重跑整个 workflow 会重新构建,不属于仅归档恢复。Actions 验收须验证输出复用。 + +生产 COS 发布默认启用官网安装包上传和缓存刷新(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 stable 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。勾选官网上传时,审批前摘要展示固定官网 URL、选定版本和无防降级保护的说明。原有版本与自动更新发布保留自身校验。 + +将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。版本发布、官网上传和缓存刷新分别使用生产审批 Environment,后续 job 和重跑可能需要再次审批。GitHub 每个并发组最多保留一个排队 job,新排队 job 可能取消旧排队 job;重跑前应检查取消记录。 + +官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。官网 URL 和保存文件名遵循[固定下载规则](../README.zh.md#fixed-downloads)。上传重跑同时核对字节和 `Content-Disposition`;字节相同但响应头缺失或错误时,上传一次并读回验证。记录已完成但远端字节改变时停止恢复,需要重新审批发布。完成标记在元数据修复失败后仍保留。每次新的上传意图清除旧刷新记录,因此修复元数据后需重新刷新缓存;上一次任务 ID 和诊断需查 EdgeOne 历史。未知写入先读取再决定重试;CDN 响应头透传仍需实测。原机器路由和准备阶段输出完整时,仅重跑刷新 job 即可查询已保存任务。启用官网上传时,官网 job 仅跳过明确选择的 test 环境。各 job 的本地预检要求其注入的 `route` 或 `selection` 输出存在。官网审批缺失或变化时,在上传或提交刷新任务前失败。`website-destination` 缺失会扩大两个官网并发组的互斥范围。`runner` 输出缺失可能导致无法调度,尚未进入预检。输出缺失时停止局部重试,workflow 不重建这些输出。重新启动发布 workflow 前,先检查当前远端状态并确认上传范围的审批;这不属于仅缓存恢复。不要为恢复发布输出重跑打包。人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,重新发起发布运行是恢复方式之一。操作者也可使用已有的[固定下载刷新命令](../README.zh.md#fixed-downloads);Windows 操作者使用该处说明的 DPAPI 凭据启动器。该命令只提交固定 URL 刷新,不观察完成状态,也不更新本次 Actions 运行的 journal。确认恢复完成前应在 EdgeOne 检查返回的任务。 + +刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;发布操作者必须在生产依赖恢复能力前验证 COS/EdgeOne 权限、job-id 筛选、时间范围查询、任务类型和时间戳格式、Actions 跨 attempt 恢复及实际 CDN 行为。 + +解决故障后,仅在上文要求的上游输出完整时重跑失败的发布 job,以保持准备阶段选定的打包尝试;否则停止局部重试。新发布可显式选择原打包运行和尝试。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,stable 写入 `stable.yml` / `stable-mac.yml`。stable 二进制位于目标二进制前缀下的 `stable/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 -频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 +频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。tag 创建被拒绝时保留其 HTTP 状态及经过校验的 GitHub request ID,后续查询不会用 tag 不存在的错误替换创建失败。仓库规则拒绝会提示操作人员检查匹配的 ruleset,不记录原始响应。 正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。 diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index eed28f7718..27b98bc350 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -14,6 +14,7 @@ function render(view) { const { locale: { id, messages }, policy, update, confirmation, navigation } = view const confirming = confirmation !== undefined const failed = update.phase === 'error' + const configurationError = failed && update.failedOperation === 'configuration' const ready = update.phase === 'ready' || (failed && update.failedOperation === 'install') const downloadable = update.phase === 'available' || (failed && update.failedOperation === 'download') const authenticationRequired = policy.error === 'authentication-required' @@ -45,13 +46,14 @@ function render(view) { detail = view.restart === 'stopping-tasks' ? messages.mandatoryStopping : messages.mandatoryRestarting status = ''; primary = ''; action = '' } - const error = localError ?? view.error ?? (authenticationRequired ? messages.policyLoginRequired : failed + const error = localError ?? view.error ?? (configurationError ? messages.updateConfigurationRead + : authenticationRequired ? messages.policyLoginRequired : failed ? update.failedOperation === 'download' ? messages.mandatoryDownloadFailed : update.failedOperation === 'install' ? preparationMessages[update.preparationFailure] ?? messages.mandatoryInstallFailed : messages.mandatoryUnavailable : update.phase === 'idle' && !policy.checking ? messages.mandatoryNoRelease : undefined) - const technicalDetails = failed ? update.technicalDetails ?? update.message ?? '' : '' + const technicalDetails = failed && !configurationError ? update.technicalDetails ?? update.message ?? '' : '' document.documentElement.lang = id document.title = messages.mandatoryTitle element('title').textContent = title @@ -73,7 +75,8 @@ function render(view) { element('update').disabled = busy && !confirming element('later').textContent = messages.updateLater element('later').hidden = !confirmation?.active - element('refresh').textContent = authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh + element('refresh').textContent = configurationError ? messages.updateConfigurationDetails + : authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh element('refresh').hidden = confirming || view.restart !== undefined || (!authenticationRequired && (!!primary || !fallback)) element('refresh').disabled = busy || policy.checking element('page').textContent = navigation ? messages.mandatoryReopen : messages.mandatoryPage diff --git a/apps/desktop/renderer/update-dialog.css b/apps/desktop/renderer/update-dialog.css index dd50411d46..583339a238 100644 --- a/apps/desktop/renderer/update-dialog.css +++ b/apps/desktop/renderer/update-dialog.css @@ -35,3 +35,8 @@ body.update-dialog.visible { opacity: 1; } .primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); } .secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); } } + +button[aria-busy="true"] { opacity: 1; display: flex; align-items: center; justify-content: center; gap: 8px; } +button[aria-busy="true"]::before { content: ""; width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: reload-spin 800ms linear infinite; } +@keyframes reload-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { button[aria-busy="true"]::before { animation: none; } } diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index d03eadadf7..44102bc012 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -3,7 +3,7 @@ const api = window.dshUpdateDialog let view let responding = false function respond(index) { - if (responding || view === undefined) return + if (responding || view === undefined || view.busyButton !== undefined) return responding = true void api.respond(view.revision, index).catch(() => { responding = false }) } @@ -11,7 +11,8 @@ document.getElementById('close').addEventListener('click', () => { respond(view. document.addEventListener('keydown', event => { if (event.key === 'Escape' && view !== undefined) { event.preventDefault(); respond(view.cancelId) } if (event.key !== 'Tab') return - const controls = [...document.querySelectorAll('button, details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + const controls = [...document.querySelectorAll('button:not(:disabled), details:not([hidden]) > summary, details[open]:not([hidden]) > pre')] + if (controls.length === 0) { event.preventDefault(); return } const current = controls.indexOf(document.activeElement) const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0) : (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length @@ -32,6 +33,7 @@ function render(state) { document.getElementById('title').textContent = state.message document.getElementById('detail').textContent = state.detail document.getElementById('detail').hidden = state.detail === '' + document.getElementById('close').disabled = state.busyButton !== undefined document.getElementById('close').setAttribute('aria-label', state.closeLabel) document.getElementById('technical-details').hidden = state.technicalDetails === '' document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel @@ -42,6 +44,8 @@ function render(state) { const button = document.createElement('button') button.type = 'button' button.textContent = label + button.disabled = state.busyButton !== undefined + if (state.busyButton === index) button.setAttribute('aria-busy', 'true') button.className = index === 0 ? 'primary' : 'secondary' button.addEventListener('click', () => { respond(index) }) document.getElementById('actions').append(button) diff --git a/apps/desktop/scripts/archive-desktop-ci.ts b/apps/desktop/scripts/archive-desktop-ci.ts new file mode 100644 index 0000000000..849433b698 --- /dev/null +++ b/apps/desktop/scripts/archive-desktop-ci.ts @@ -0,0 +1,31 @@ +/** Validate the original machine and immutable inventory before GitHub artifact archival. */ +import { resolve } from 'node:path' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' + +/** + * Verify an original build without interpreting the archive job's attempt as a new build. + * @param env Original build outputs and current Actions machine identity. + * @returns Resolves after the retained inventory passes all digest checks. + */ +export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise { + if (!env.RUNNER_NAME || env.RUNNER_NAME !== env.DESKTOP_PACKAGE_RUNNER) throw new Error('desktop archival: wrong original machine') + const root = desktopReleasesRoot(env) + const runId = env.GITHUB_RUN_ID || '' + const attempt = env.DESKTOP_PACKAGE_ATTEMPT || '' + const expected = retainedDesktopPath(root, runId, attempt) + if (env.DESKTOP_RETAINED_DIRECTORY !== expected) throw new Error('desktop archival: retained directory differs from original build') + const manifest = await verifyRetainedDesktopRelease(root, runId, attempt) + if (manifest.source.repository !== env.GITHUB_REPOSITORY || manifest.source.runnerName !== env.RUNNER_NAME) { + throw new Error('desktop archival: retained source does not match repository or machine') + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + try { await verifyDesktopArchive(process.env) } + catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'artifact archival'))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index 4d42b3dd1e..c58df5a048 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -14,10 +14,17 @@ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' */ export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string +/** + * Resolve the production website installer URL shared by approval, upload and purge. + * @param target Supported Desktop target. + * @returns Fixed production download URL. + */ +export function desktopWebsiteInstallerUrl(target: DesktopAutoUpdateTarget): string + /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string @@ -68,7 +75,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str export function desktopUpdateMetadataFilename( version: string, platform: NodeJS.Platform, - channel?: 'nightly' | 'latest', + channel?: 'nightly' | 'stable', ): string /** @@ -104,7 +111,7 @@ export function resolveDesktopUploadConfig( * @param env Packaging settings. * @returns Validated channel. */ -export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' +export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'stable' /** * Read a required nonempty deployment setting. diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index 835beadd6d..24aaa0ea2c 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -8,11 +8,11 @@ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' /** * Resolve the channel embedded in the installer; omitted local settings retain nightly. * @param {NodeJS.ProcessEnv} env Packaging or upload settings. - * @returns {'nightly' | 'latest'} Validated channel. + * @returns {'nightly' | 'stable'} Validated channel. */ export function resolveDesktopUpdateChannel(env) { const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly' - if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel') + if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop auto-update: invalid update channel') return channel } @@ -46,6 +46,15 @@ export function desktopLatestInstallerFilename(target) { return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}` } +/** + * Resolve the production website installer URL shared by approval, upload and purge. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target. + * @returns {string} Fixed production download URL. + */ +export function desktopWebsiteInstallerUrl(target) { + return `${UPDATE_ENVIRONMENTS.production.fixedOrigin}/desktop/${desktopLatestInstallerFilename(target)}` +} + /** * Resolve the update deployment, defaulting local release work to test. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. @@ -90,7 +99,7 @@ export function desktopBuildRecordFilename(target) { * Return the electron-builder channel metadata filename for an application version. * @param {string} version - Desktop semantic version. * @param {NodeJS.Platform} platform - Target platform. - * @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly. + * @param {'nightly' | 'stable'} channel - Selected channel; omitted means nightly. * @returns {string} Channel metadata filename emitted for the target. */ export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') { @@ -148,7 +157,7 @@ function httpsOrigin(value, name) { * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { @@ -177,7 +186,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { target, origin, keyPrefix, - binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`, + binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'stable' ? '/stable' : ''}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -187,7 +196,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 8fef629287..3822b8329f 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -24,7 +24,7 @@ export function desktopCiVersion(environment, productVersion, now = new Date()) } candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } - if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version') + if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'stable' && candidate.includes('-')) throw new Error('Stable channel requires a stable version; select product or manual stable version') try { const resolved = validateDesktopBuildVersion(candidate, productVersion) return resolved diff --git a/apps/desktop/scripts/desktop-file-settings.d.mts b/apps/desktop/scripts/desktop-file-settings.d.mts new file mode 100644 index 0000000000..5b2597341c --- /dev/null +++ b/apps/desktop/scripts/desktop-file-settings.d.mts @@ -0,0 +1,9 @@ +/** Read file-owned packaging settings without loading packaging dependencies. */ + +/** + * Read an explicitly selected dotenv without exposing credential-bearing parser errors. + * @param path - Selected file; relative file settings resolve beside it. + * @param accepts - Whether this file may declare a setting. + * @returns Validated file settings. + */ +export function readDesktopPackageSettings(path: string, accepts: (name: string) => boolean): Record diff --git a/apps/desktop/scripts/desktop-file-settings.mjs b/apps/desktop/scripts/desktop-file-settings.mjs new file mode 100644 index 0000000000..8c48f9e3f0 --- /dev/null +++ b/apps/desktop/scripts/desktop-file-settings.mjs @@ -0,0 +1,40 @@ +/** Read file-owned packaging settings without loading packaging dependencies. */ +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { parseEnv } from 'node:util' + +const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] + +/** + * Read an explicitly selected dotenv without exposing credential-bearing parser errors. + * @param {string} path Selected file; relative file settings resolve beside it. + * @param {(name: string) => boolean} accepts Whether this file may declare a setting. + * @returns {Record} Validated file settings. + */ +export function readDesktopPackageSettings(path, accepts) { + let contents + try { + contents = readFileSync(path, 'utf8') + } + catch { + throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`) + } + let settings + try { + settings = parseEnv(contents.replace(/^\uFEFF/u, '')) + } + catch { + // Parser diagnostics can contain credential-bearing input. + throw new Error(`desktop package: invalid dotenv syntax in ${path}`) + } + for (const name of Object.keys(settings)) { + if (!accepts(name)) { + throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`) + } + if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`) + } + for (const name of FILE_SETTINGS) { + if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim()) + } + return settings +} diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 27a7f92c7f..83e9dbbc0f 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -1,9 +1,9 @@ /** Select local test defaults or platform release settings without changing the caller's environment. */ -import { accessSync, constants, readFileSync, statSync } from 'node:fs' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { accessSync, constants, statSync } from 'node:fs' +import { dirname, isAbsolute, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { parseEnv } from 'node:util' +import { readDesktopPackageSettings as readSettings } from './desktop-file-settings.mjs' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs' import { createWindowsTokenSigner } from './windows-sign.mjs' @@ -19,7 +19,6 @@ const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|M const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu -const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] /** * Select local macOS test defaults without reading dotenv files or inheriting release settings. @@ -101,40 +100,6 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en } } -/** - * Read an explicitly selected dotenv without exposing credential-bearing parser errors. - * @param {string} path Selected file; relative file settings resolve beside it. - * @param {(name: string) => boolean} accepts Whether this file may declare a setting. - * @returns {NodeJS.ProcessEnv} Validated file settings. - */ -function readSettings(path, accepts) { - let contents - try { - contents = readFileSync(path, 'utf8') - } - catch { - throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`) - } - let settings - try { - settings = parseEnv(contents.replace(/^\uFEFF/u, '')) - } - catch { - // Parser diagnostics can contain credential-bearing input. - throw new Error(`desktop package: invalid dotenv syntax in ${path}`) - } - for (const name of Object.keys(settings)) { - if (!accepts(name)) { - throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`) - } - if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`) - } - for (const name of FILE_SETTINGS) { - if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim()) - } - return settings -} - function requireReadableFile(environment, name) { try { if (!statSync(environment[name]).isFile()) throw new Error('not a file') diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts index 930c23da2d..3772fb0b67 100644 --- a/apps/desktop/scripts/desktop-publication-failure.ts +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -1,5 +1,6 @@ /** Safe failure details shared by publication diagnostics and durable journals. */ import { ZodError } from 'zod' +import { DesktopPurgeExecutionError } from './desktop-purge-recovery.ts' import { DesktopPublishError } from './desktop-publish.ts' const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch', @@ -39,12 +40,15 @@ export function desktopPublicationFailure(error: unknown, phase: string): { ...(typeof requestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(requestId) ? { requestId } : {}), ...(typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}) } } + if (error instanceof DesktopPurgeExecutionError) return { phase, code: `purge-${error.status}`, message: error.message } if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message } if (error instanceof ZodError) return { phase, code: 'invalid-data', message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } - const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u - if (error instanceof Error && ownedMessage.test(error.message)) { + const ownedMessage = + /^(desktop (?:publish|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u + const websiteMessage = /^website (?:publication|installer|cache purge):/u + if (error instanceof Error && (ownedMessage.test(error.message) || websiteMessage.test(error.message))) { return { phase, code: 'validation', message: error.message } } const record = typeof error === 'object' && error !== null ? error : {} diff --git a/apps/desktop/scripts/desktop-publication-summary.ts b/apps/desktop/scripts/desktop-publication-summary.ts index 4d2d437dd3..49d438aaf2 100644 --- a/apps/desktop/scripts/desktop-publication-summary.ts +++ b/apps/desktop/scripts/desktop-publication-summary.ts @@ -1,6 +1,7 @@ /** Render the verified publication plan for GitHub's native deployment reviewers. */ import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' +import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs' import type { DesktopRetainedSource } from './desktop-release-store.ts' import type { DesktopUploadPlan } from './desktop-upload-plan.ts' @@ -18,10 +19,11 @@ function link(url: string, label = url): string { return ` { const origin = new URL(plan.publicUrl).origin const url = (key: string) => `${origin}/${key.split('/').map(encodeURIComponent).join('/')}` @@ -52,6 +54,8 @@ export async function desktopPublicationSummary( row('升级 YAML', `${feeds.map(file => link(url(file.key))).join('
')}
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。`), row('打包来源', `${link(runUrl, `查看打包运行 #${source.runId} · 第 ${source.attempt} 次尝试`)}
源码:${code(`${source.sourceRef} @ ${source.commit}`)}
打包机器:${code(source.runnerName)}`), row('签名与公证', plan.target === 'win-x64' ? 'EXE 签名校验通过' : 'DMG 校验通过
ZIP 内应用校验通过'), + ...(uploadWebsite && plan.environment === 'production' ? [row('官网固定安装包', + `${link(desktopWebsiteInstallerUrl(plan.target))}
替换为版本 ${code(plan.version)};已有安装包会被替换,无防降级保护;官网上传与刷新在后续 job 中执行,可能需另行审批。`)] : []), row('Git tag', tag), '', '### 批准后将执行', '', '1. 重新校验产物与发布目标;若与审批摘要不一致则停止。', '2. 上传缺少的文件,跳过内容一致的已有文件;发现内容冲突则停止。', diff --git a/apps/desktop/scripts/desktop-publish-cos.ts b/apps/desktop/scripts/desktop-publish-cos.ts index 00a6f108d1..086303238e 100644 --- a/apps/desktop/scripts/desktop-publish-cos.ts +++ b/apps/desktop/scripts/desktop-publish-cos.ts @@ -49,8 +49,9 @@ export function desktopPublishCos( }) try { const cos = client() - await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output })) - return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) } + const response = await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output })) + return { size, sha512: sha512.digest('base64'), + ...(typeof response.headers?.['content-disposition'] === 'string' ? { contentDisposition: response.headers['content-disposition'] } : {}), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) } } catch (error) { if (missing(error)) return null throw new DesktopCosOperationError('GetObject', error) @@ -71,7 +72,8 @@ export function desktopPublishCos( try { await cosOperation(cos, 900_000, () => cos.putObject({ ...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType, - Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) }, + Headers: { 'Content-MD5': md5.digest('base64'), + ...(artifact.contentDisposition ? { 'Content-Disposition': artifact.contentDisposition } : {}), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) }, })) } catch (error) { throw new DesktopCosOperationError('PutObject', error) diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 9b6fedfa42..dfd480842c 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -1,4 +1,4 @@ -/** Validate a completed packaging attempt and route publication back to its original runner. */ +/** Validate package-job success and route publication; attempt selection enforces run completion. */ import { z } from 'zod' import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' @@ -6,13 +6,13 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio const positive = z.number().int().positive() const runSchema = z.object({ id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u), - path: z.string(), conclusion: z.literal('success'), event: z.literal('workflow_dispatch'), + path: z.string(), event: z.literal('workflow_dispatch'), repository: z.object({ full_name: z.string() }), }) const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string(), sourceRef: z.string().min(1), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), }).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), @@ -25,7 +25,7 @@ export interface DesktopPublishRoute { readonly attempt: string readonly commit: string readonly sourceRef: string - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly runnerName: string readonly runnerLabel: string readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64' diff --git a/apps/desktop/scripts/desktop-publish-tag.ts b/apps/desktop/scripts/desktop-publish-tag.ts index f47cc698a6..de60c03e84 100644 --- a/apps/desktop/scripts/desktop-publish-tag.ts +++ b/apps/desktop/scripts/desktop-publish-tag.ts @@ -40,8 +40,32 @@ export async function publishDesktopTag( body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000), }) if (response.status === 201) { await matches(response); return 'created' } - // A parallel platform release may have created the same version tag after our first read. - if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' } + const requestId = response.headers.get('x-github-request-id') + const identity = requestId !== null && /^[A-Fa-f0-9:]{1,128}$/u.test(requestId) ? `; GitHub request ${requestId}` : '' + const failure = `desktop tag: creation failed (HTTP ${response.status}${identity})` + // A 422 can reject repository rules as well as report a concurrent tag creation. + if (response.status === 422) { + let detail: unknown + try { detail = await response.json() } catch (error) { + // Non-JSON error responses still retain the original HTTP status and request ID. + } + const rulesRejected = typeof detail === 'object' && detail !== null && 'message' in detail + && typeof detail.message === 'string' && detail.message.startsWith('Repository rule violations found') + const guidance = rulesRejected ? 'repository rules rejected tag creation; inspect the matching tag rulesets' + : 'inspect repository rule insights and App permissions before retrying' + let reconciled: Response + try { reconciled = await get() } catch (cause) { + throw new Error(`${failure}; follow-up tag lookup failed; ${guidance}`, { cause }) + } + if (!reconciled.ok) { + await reconciled.body?.cancel() + throw new Error(`${failure}; follow-up tag lookup returned HTTP ${reconciled.status}; ${guidance}`) + } + try { await matches(reconciled) } catch (cause) { + throw new Error(`${failure}; follow-up tag verification failed; inspect the existing tag before retrying`, { cause }) + } + return 'present' + } await response.body?.cancel() - throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`) + throw new Error(`${failure}; reconcile the tag before retrying`) } diff --git a/apps/desktop/scripts/desktop-publish.ts b/apps/desktop/scripts/desktop-publish.ts index 1270f7229e..d11e2bd098 100644 --- a/apps/desktop/scripts/desktop-publish.ts +++ b/apps/desktop/scripts/desktop-publish.ts @@ -8,8 +8,8 @@ import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload- /** File identity computed from bytes, never inferred from COS ETags. */ export interface DesktopObjectDigest { readonly size: number; readonly sha512: string } -/** Remote object observation; channel objects additionally carry their exact YAML. */ -export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string } +/** Remote bytes, optional download filename header, and exact YAML for channel objects. */ +export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string; readonly contentDisposition?: string } /** Transport owned by the caller holding the destination channel lock. */ export interface DesktopPublishTransport { /** Read and hash remote bytes; only a confirmed missing object returns null. */ diff --git a/apps/desktop/scripts/desktop-purge-recovery.ts b/apps/desktop/scripts/desktop-purge-recovery.ts new file mode 100644 index 0000000000..890ea003ba --- /dev/null +++ b/apps/desktop/scripts/desktop-purge-recovery.ts @@ -0,0 +1,149 @@ +/** Resume a fixed-URL purge without repeating ambiguous submissions. */ +import type { teo } from 'tencentcloud-sdk-nodejs-teo' +import type { DesktopPurgeConfig } from './desktop-edgeone.ts' + +/** Non-secret evidence saved before submission and after every observation. */ +export interface DesktopPurgeRecord { + readonly zoneId: string + readonly publicUrl: string + readonly submittedAt: string + readonly jobId?: string | undefined + readonly status: 'pending' | 'processing' | 'success' | 'failed' | 'timeout' | 'canceled' | 'unknown' + readonly observedAt?: string | undefined + readonly diagnostic?: string | undefined +} + +/** SDK requests used by the recovery owner. */ +export type DesktopPurgeRecoveryClient = Pick, 'CreatePurgeTask' | 'DescribePurgeTasks'> + +/** Explicit bounded observation policy; writes are never automatically retried. */ +export interface DesktopPurgeRecoveryOptions { + readonly intervalMs: number + readonly waitMs: number + readonly allowResubmit: boolean + readonly now: () => number + readonly sleep: (milliseconds: number) => Promise + readonly save: (record: DesktopPurgeRecord) => Promise +} + +/** A confirmed remote failure, distinct from an unconfirmed observation. */ +export class DesktopPurgeExecutionError extends Error { + constructor(readonly status: 'failed' | 'timeout' | 'canceled') { + super(`website cache purge: ${status}; inspect the task before manually rerunning`) + } +} + +function failed(status: DesktopPurgeRecord['status']): status is 'failed' | 'timeout' | 'canceled' { + return status === 'failed' || status === 'timeout' || status === 'canceled' +} + +async function observe(record: DesktopPurgeRecord, client: DesktopPurgeRecoveryClient, + now: number, config: DesktopPurgeConfig): Promise { + const observedAt = new Date(now).toISOString() + const unknown = (diagnostic: string): DesktopPurgeRecord => ({ ...record, status: 'unknown', observedAt, diagnostic }) + const matches = [] + try { + let offset = 0 + do { + const response = await client.DescribePurgeTasks({ + ZoneId: record.zoneId, Limit: 1000, Offset: offset, + ...(record.jobId ? { Filters: [{ Name: 'job-id', Values: [record.jobId] }] } : { + StartTime: record.submittedAt, + EndTime: new Date(Math.min(now, Date.parse(record.submittedAt) + 120_000)).toISOString(), + }), + }) + if (!Array.isArray(response.Tasks) || typeof response.TotalCount !== 'number') return unknown('invalid query response') + for (const task of response.Tasks) { + if (task.Target !== record.publicUrl || task.Type !== 'purge_url') continue + if (record.jobId ? task.JobId !== record.jobId + : !task.CreateTime || !Number.isFinite(Date.parse(task.CreateTime)) + || Date.parse(task.CreateTime) < Date.parse(record.submittedAt) + || Date.parse(task.CreateTime) > Date.parse(record.submittedAt) + 120_000) continue + matches.push(task) + } + offset += response.Tasks.length + if (offset >= response.TotalCount) break + if (response.Tasks.length === 0 || offset >= 10_000) return unknown('task history could not be fully inspected') + } while (true) + } catch (error) { return unknown('task query failed; check EdgeOne history and DescribePurgeTasks permission') } + if (matches.length === 0) return unknown(record.jobId + ? 'accepted task not found in query history; execution result unconfirmed' + : 'no matching submission found; purge may not have been submitted; inspect EdgeOne history before manual recovery') + if (matches.length !== 1) return unknown('multiple matching submissions found; cannot identify this submission') + const task = matches[0]! + if (!task.JobId || !/^[\w.-]{1,128}$/u.test(task.JobId) + || task.JobId.includes(config.secretId) || task.JobId.includes(config.secretKey)) return unknown('query returned no usable task ID') + const status = task.Status + if (status !== 'processing' && status !== 'success' && status !== 'failed' && status !== 'timeout' && status !== 'canceled') { + return unknown('query returned an unknown task status') + } + return { zoneId: record.zoneId, publicUrl: record.publicUrl, submittedAt: record.submittedAt, + jobId: task.JobId, status, observedAt } +} + +/** + * Submit at most once, or reconcile an earlier submission, then observe bounded completion. + * Unknown results receive one additional query and return unknown for warning-only Actions completion. + * @param config Validated destination and credentials; credentials never enter records. + * @param previous Durable record, or undefined for a new publication. + * @param client SDK client with bounded requests and no submission retry policy. + * @param options Clock, persistence, timing and explicit manual-resubmission authorization. + * @returns Successful or unconfirmed result; confirmed failures throw after durable recording. + */ +export async function recoverDesktopPurge(config: DesktopPurgeConfig, previous: DesktopPurgeRecord | undefined, + client: DesktopPurgeRecoveryClient, options: DesktopPurgeRecoveryOptions): Promise { + if (options.intervalMs <= 0 || options.waitMs < 0) throw new Error('website cache purge: invalid observation budget') + if (previous && (previous.zoneId !== config.zoneId || previous.publicUrl !== config.publicUrl)) { + throw new Error('website cache purge: saved destination differs from selected publication') + } + let record = previous + let observedPrevious = false + if (record) { + record = await observe(record, client, options.now(), config) + observedPrevious = true + await options.save(record) + if (record.status === 'success') return record + if (failed(record.status)) { + if (!options.allowResubmit) throw new DesktopPurgeExecutionError(record.status) + record = undefined + observedPrevious = false + } + } + if (!record) { + record = { zoneId: config.zoneId, publicUrl: config.publicUrl, + submittedAt: new Date(Math.floor(options.now() / 1000) * 1000).toISOString(), status: 'pending' } + await options.save(record) + let response: Awaited> | undefined + try { response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) } + catch (error) { /* Submission may have reached EdgeOne; reconcile history without repeating the request. */ } + const jobId = response?.JobId + if (response?.FailedList?.length) { + record = { ...record, status: 'failed' } + } else if (typeof jobId === 'string' && /^[\w.-]{1,128}$/u.test(jobId) + && !jobId.includes(config.secretId) && !jobId.includes(config.secretKey)) { + record = { ...record, status: 'processing', jobId } + } else { + record = { ...record, status: 'unknown', diagnostic: 'submission acceptance unconfirmed; checking task history' } + } + await options.save(record) + if (failed(record.status)) throw new DesktopPurgeExecutionError(record.status) + } + let current: DesktopPurgeRecord = record + const deadline = options.now() + options.waitMs + let extraQuery = false + while (true) { + const observed: DesktopPurgeRecord = observedPrevious ? current : await observe(current, client, options.now(), config) + observedPrevious = false + current = observed + await options.save(current) + if (current.status === 'success') return current + if (failed(current.status)) throw new DesktopPurgeExecutionError(current.status) + if (extraQuery) { + current = { ...current, status: 'unknown', diagnostic: current.diagnostic ?? (options.now() >= deadline ? 'observation deadline exceeded' : 'task still processing after an additional query') } + await options.save(current) + return current + } + if (current.status === 'unknown' || options.now() >= deadline) extraQuery = true + await options.sleep(options.intervalMs) + } +} diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index c080272e1d..eb9d5457ff 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -14,7 +14,7 @@ const sourceSchema = z.strictObject({ commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), sourceRef: z.string().min(1), - channel: z.enum(['nightly', 'latest']), + channel: z.enum(['nightly', 'stable']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), @@ -121,13 +121,13 @@ export async function retainDesktopRelease(root: string, source: DesktopRetained } /** - * Read an attempt and rehash every retained file before a consumer uses it. + * Read and validate manifest identity without requiring unrelated retained files. * @param root Persistent releases root. * @param runId Expected source run ID. * @param attempt Expected source attempt. - * @returns Parsed inventory; missing, linked, changed or mismatched files reject. + * @returns Parsed inventory; consumers must verify the files they use against their recorded digests. */ -export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise { +export async function readRetainedDesktopManifest(root: string, runId: string, attempt: string): Promise { const path = retainedDesktopPath(root, runId, attempt) await directory(root) await directory(join(root, runId)) @@ -137,6 +137,19 @@ export async function verifyRetainedDesktopRelease(root: string, runId: string, const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8'))) if (manifest.source.runId !== runId || manifest.source.attempt !== attempt) throw new Error('desktop release store: attempt identity mismatch') if (new Set(manifest.files.map(file => file.name)).size !== manifest.files.length) throw new Error('desktop release store: duplicate file inventory') + return manifest +} + +/** + * Verify every retained file against its immutable build manifest. + * @param root Persistent releases directory. + * @param runId Original packaging run. + * @param attempt Original packaging attempt, unchanged by archival reruns. + * @returns Manifest after all retained files pass size and hash verification. + */ +export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise { + const manifest = await readRetainedDesktopManifest(root, runId, attempt) + const path = retainedDesktopPath(root, runId, attempt) for (const file of manifest.files) { const current = await fingerprint(join(path, file.name)) if (current.size !== file.size || current.sha512 !== file.sha512) throw new Error(`desktop release store: changed file ${file.name}`) diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 1461444fc6..e6a8d4c7f0 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -33,6 +33,8 @@ export interface DesktopUploadArtifact { readonly filename: string readonly key: string readonly contentType: string + /** Browser save name for fixed website downloads, preserving the validated installer basename. */ + readonly contentDisposition?: string readonly channelMetadata: boolean /** Published YAML with normalized artifact URLs; binary bytes remain file-backed. */ readonly contents?: string @@ -219,7 +221,7 @@ export async function createDesktopUploadPlan( throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version') + if (update.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop upload: stable requires a stable version') const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown @@ -288,7 +290,8 @@ export async function createDesktopUploadPlan( bucket: update.bucket, secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, - artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey }] : artifacts, + artifacts: options.latest ? [{ ...installerArtifact, filename: latestFilename, key: latestKey, + contentDisposition: `attachment; filename="${installerArtifact.filename}"` }] : artifacts, ...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {}, ...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {}, } diff --git a/apps/desktop/scripts/desktop-upload-run.ts b/apps/desktop/scripts/desktop-upload-run.ts index aa3cd49aa5..3bb305afde 100644 --- a/apps/desktop/scripts/desktop-upload-run.ts +++ b/apps/desktop/scripts/desktop-upload-run.ts @@ -101,7 +101,7 @@ export async function uploadDesktopRelease(plan: DesktopUploadPlan, cos: COS, re try { const response = await cos.putObject({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key, Body: body, ContentLength: artifact.size, ContentType: artifact.contentType, - Headers: { 'Content-MD5': artifact.md5 } }) + Headers: { 'Content-MD5': artifact.md5, ...(artifact.contentDisposition ? { 'Content-Disposition': artifact.contentDisposition } : {}) } }) confirmedPuts++ stage = 'record-response' recordPackagingEvent(directory, { type: 'put-confirmed', key, attempts: 1, ...receipt(response) }) diff --git a/apps/desktop/scripts/desktop-website-installer.ts b/apps/desktop/scripts/desktop-website-installer.ts new file mode 100644 index 0000000000..8e3838481f --- /dev/null +++ b/apps/desktop/scripts/desktop-website-installer.ts @@ -0,0 +1,116 @@ +/** Validate one production installer and hold its file handle through COS publication. */ +import { createHash } from 'node:crypto' +import { open, lstat, type FileHandle } from 'node:fs/promises' +import { join } from 'node:path' +import type { DesktopRetainedManifest } from './desktop-release-store.ts' +import type { DesktopPublishRoute } from './desktop-publish-route.ts' +import { desktopLatestInstallerFilename, desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs' +import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from './desktop-cos.ts' +import { cosOperation } from './cos-operation.ts' +import type { DesktopObjectDigest, DesktopPublishTransport } from './desktop-publish.ts' +import { DesktopCosOperationError } from './desktop-publication-failure.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' + +/** Verified file and destination; the caller closes the handle after publication. */ +export interface DesktopWebsiteInstaller { + readonly plan: DesktopUploadPlan + readonly digest: DesktopObjectDigest + readonly md5: string + readonly contentDisposition: string + readonly handle: FileHandle +} + +/** + * Check production build identity and hash only the selected installer once. + * @param directory Retained build directory on the original runner. + * @param manifest Trusted retained build inventory matched against GitHub evidence. + * @param route Pinned build identity from the credential-free routing job. + * @param repository Repository expected by the workflow. + * @param bucket Approved production COS bucket. + * @returns An open verified installer; callers must close its handle in finally. + */ +export async function prepareWebsiteInstaller(directory: string, manifest: DesktopRetainedManifest, + route: DesktopPublishRoute, repository: string, bucket: string): Promise { + const source = manifest.source + if (route.preset !== 'production' || source.preset !== 'production' + || source.repository !== repository || source.runId !== route.runId || source.attempt !== route.attempt + || source.commit !== route.commit || source.runnerName !== route.runnerName || source.target !== route.target + || source.sourceRef !== route.sourceRef || source.channel !== route.channel + || source.qualification !== (route.target === 'win-x64' ? 'signed' : 'notarized') + || !/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef)) { + throw new Error('website installer: build identity or production qualification mismatch') + } + const windows = route.target === 'win-x64' + const filename = `deepseek-harness-${source.version}-${route.target}.${windows ? 'exe' : 'dmg'}` + const file = manifest.files.find(item => item.name === filename) + if (!file) throw new Error('website installer: selected installer is absent from build manifest') + const path = join(directory, file.name) + const info = await lstat(path) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('website installer: expected a regular installer file') + const handle = await open(path, 'r') + try { + const sha512 = createHash('sha512') + const md5 = createHash('md5') + let size = 0 + for await (const bytes of handle.createReadStream({ autoClose: false, start: 0 })) { + size += bytes.length + sha512.update(bytes) + md5.update(bytes) + } + const digest = { size, sha512: sha512.digest('base64') } + if (size !== file.size || digest.sha512 !== file.sha512) throw new Error('website installer: SHA-512 or size differs from build manifest') + const key = `desktop/${desktopLatestInstallerFilename(route.target)}` + const contentDisposition = `attachment; filename="${file.name}"` + return { handle, digest, contentDisposition, md5: md5.digest('base64'), plan: { + environment: 'production', target: route.target, version: source.version, commit: source.commit, dirty: false, + bucket, publicUrl: desktopWebsiteInstallerUrl(route.target), + secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', + artifacts: [{ path, filename: file.name, key, channelMetadata: false, + contentDisposition, + contentType: windows ? 'application/vnd.microsoft.portable-executable' : 'application/x-apple-diskimage' }], + } } + } catch (error) { + await handle.close() + throw error + } +} + +/** + * Reconcile an earlier upload or replace the fixed object once using the already verified handle. + * @param installer Open installer verified against the selected build. + * @param credentials Production upload credentials, never logged. + * @param transport Read-only reconciliation adapter using the same destination. + * @param record Durable event writer; intent must persist before the remote write. + * @param options Optional COS factory and durable completion evidence; completed uploads cannot replace different remote bytes. + * @returns Whether bytes were uploaded or an existing identical object was reused. + */ +export async function uploadWebsiteInstaller(installer: DesktopWebsiteInstaller, credentials: DesktopCosCredentials, + transport: Pick, + record: (event: 'upload-intent' | 'uploaded') => Promise, + options: { createClient?: typeof createDesktopCos; previouslyUploaded?: boolean } = {}, +): Promise<'uploaded' | 'present'> { + const artifact = installer.plan.artifacts[0]! + const matches = (remote: DesktopObjectDigest | null) => + remote?.size === installer.digest.size && remote.sha512 === installer.digest.sha512 + const remote = await transport.read(artifact.key, false) + if (options.previouslyUploaded && !matches(remote)) throw new Error(`website installer: recorded upload no longer matches remote bytes (${remote === null ? 'object missing' : 'bytes changed'}); select and approve a new publication`) + if (matches(remote) && remote?.contentDisposition === installer.contentDisposition) { + await record('uploaded') + return 'present' + } + await record('upload-intent') + const body = installer.handle.createReadStream({ autoClose: false, start: 0 }) + const cos = (options.createClient ?? createDesktopCos)(credentials) + try { + await cosOperation(cos, 900_000, () => cos.putObject({ Bucket: installer.plan.bucket, Region: DESKTOP_COS_REGION, + Key: artifact.key, Body: body, ContentLength: installer.digest.size, ContentType: artifact.contentType, + Headers: { 'Content-MD5': installer.md5, 'Content-Disposition': installer.contentDisposition }, + })) + } catch (error) { throw new DesktopCosOperationError('PutObject', error) } + finally { body.destroy() } + const confirmed = await transport.read(artifact.key, false) + if (!matches(confirmed)) throw new Error(`website installer: remote bytes unconfirmed; expected ${JSON.stringify(installer.digest)}, observed ${JSON.stringify(confirmed)}; reconcile before retrying`) + if (confirmed?.contentDisposition !== installer.contentDisposition) throw new Error(`website installer: remote download filename unconfirmed; expected ${JSON.stringify(installer.contentDisposition)}, observed ${JSON.stringify(confirmed?.contentDisposition ?? null)}; inspect COS metadata before retrying`) + await record('uploaded') + return 'uploaded' +} diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index cf0c19e895..c2563a0edf 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -76,7 +76,10 @@ export function createElectronBuilderConfig( let dshDestination let windowsCode = [] const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg', - `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] + `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`, + // LaunchServices opens the Computer Use helper as an application, so every file of its bundles stays on disk. + // Both architectures ship: they form one notarized payload whose manifest staging verifies as a whole. + '**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*'] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, @@ -102,7 +105,7 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) - if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version') + if (update?.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop package: stable requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, diff --git a/apps/desktop/scripts/logged-notarytool.mjs b/apps/desktop/scripts/logged-notarytool.mjs index ae2a9bf055..a9ed964b3c 100755 --- a/apps/desktop/scripts/logged-notarytool.mjs +++ b/apps/desktop/scripts/logged-notarytool.mjs @@ -5,7 +5,7 @@ import { basename, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs' -import { packagingStep } from './packaging-step.mjs' +import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' const exec = promisify(execFile) const AUTH_FLAGS = new Set(['--apple-id', '--password', '--team-id', '--key', '--key-id', '--issuer', '--keychain', '--keychain-profile']) @@ -17,7 +17,8 @@ async function execute(args) { } catch (error) { // Apple can report rejection with a nonzero exit; electron-notarize needs its JSON ID to retrieve diagnostics. if (args[0] === 'wait' && typeof error.code === 'number' && typeof error.stdout === 'string' && error.stdout.trim()) return error.stdout - throw error + throw new Error(`notarytool ${args[0]} failed (code: ${String(error.code ?? 'unknown')}, signal: ${String(error.signal ?? 'none')})\n` + + `stdout: ${error.stdout ?? ''}\nstderr: ${error.stderr ?? ''}`, { cause: error }) } } @@ -74,10 +75,13 @@ export async function runLoggedNotarytool(args, directory, invoke = execute) { } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { - try { process.stdout.write(await runLoggedNotarytool(process.argv.slice(2), process.env.DSH_DESKTOP_PACKAGING_RUN_DIR)) } - catch { - // execFile errors include credential-bearing argv; detailed failures are redacted in the journal. - process.stderr.write('desktop notarization: Apple command failed; see packaging events.jsonl\n') + const args = process.argv.slice(2) + const directory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + try { process.stdout.write(await runLoggedNotarytool(args, directory)) } + catch (error) { + const credentials = args.filter((_, index) => AUTH_FLAGS.has(args[index - 1])) + process.stderr.write(`desktop notarization: Apple command failed${directory ? `; see ${directory}/events.jsonl` : ''}\n` + + `${packagingErrorDetails(error, credentials)}\n`) process.exitCode = 1 } } diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts index ce18266c48..926762b9ec 100644 --- a/apps/desktop/scripts/macos-app-update-config.d.mts +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -1,14 +1,14 @@ /** Resolved fields required to embed a macOS updater feed. */ export interface MacOSAppUpdateFeed { readonly publicUrl: string - readonly channel?: 'nightly' | 'latest' + readonly channel?: 'nightly' | 'stable' } /** Packaged electron-updater configuration for macOS. */ export interface MacOSAppUpdateConfig { readonly provider: 'generic' readonly url: string - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' readonly updaterCacheDirName: string } diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs index d6b4132c71..bdb400bcde 100644 --- a/apps/desktop/scripts/macos-app-update-config.mjs +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -24,24 +24,24 @@ function nonEmptyString(value, label) { /** * Resolve the one generic macOS feed from the final electron-builder configuration. * @param {unknown} publish - Final electron-builder publish setting. - * @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App. + * @returns {{ publicUrl: string, channel?: 'nightly' | 'stable' }} Resolved feed used by the packaged App. */ export function resolveMacOSAppUpdateFeed(publish) { if (!Array.isArray(publish) || publish.length !== 1) { throw new Error('desktop macOS update config: publish must contain exactly one provider') } const provider = object(publish[0], 'publish provider') - if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) { - throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel') + if (provider.provider !== 'generic' || !['nightly', 'stable'].includes(provider.channel)) { + throw new Error('desktop macOS update config: publish provider must be generic with nightly or stable channel') } return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) } } /** * Create the electron-updater configuration embedded before code signing. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. - * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields. + * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'stable', updaterCacheDirName: string }} Packaged updater fields. */ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { return { @@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { /** * Write the updater configuration into an assembled App before signing. * @param {string} resourcesDir - App Contents/Resources directory. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. * @returns {Promise} Resolves after the configuration is durable. */ @@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac /** * Verify the updater configuration inside an assembled macOS App. * @param {string} appPath - Application bundle path. - * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Expected update feed. * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. * @returns {Promise} Resolves when the packaged configuration matches the release destination. */ diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 1d4c27c0d5..16b8fe2a27 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -8,9 +8,15 @@ import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts' import { macOSCachePolicy } from './macos-cache-policy.ts' -import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' +import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca']) +/** + * The notarized Computer Use helper bundle containing a runtime path, as staged by `scripts/cua-helper/stage.ts`: + * `native/bin/darwin-//.app`. Other bundles, such as LibreOffice's unsigned application, + * are not matched and keep per-file runtime signing. + */ +const CUA_HELPER_BUNDLE = /^(node_modules\/@deepseek-ai\/dsh-experimental-cua-macos\/native\/bin\/darwin-[^/]+\/[^/]+\/[^/]+\.app)\//u function magic(path: string): string { const descriptor = openSync(path, 'r') @@ -33,21 +39,32 @@ async function primaryRuntimeFiles(root: string): Promise { } /** - * Sign and verify every materialized Mach-O file, awaiting all signers on failure. + * Sign and verify every materialized Mach-O file, awaiting all signers on failure. Files inside the notarized Computer + * Use helper bundles are not re-signed, because signing them breaks the bundle seal and changes the identity macOS + * grants permissions to; each such bundle's deep signature, release identity, stapled ticket, and Gatekeeper + * acceptance are verified before any file is signed. * @param root - Self-contained production runtime. * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. * @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link. - * @returns Number of signed native files. + * @returns Number of signed native files, excluding files inside verified Computer Use helper bundles. + * @throws When a helper bundle fails verification, or when any signer fails. */ export async function signMacOSRuntime( root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, kind: 'desktop' | 'primary' = 'desktop', ): Promise { const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path) - const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) + const bundles = new Set() + const files: string[] = [] + for (const path of inventory) { + const bundle = CUA_HELPER_BUNDLE.exec(path)?.[1] + if (bundle !== undefined) bundles.add(bundle) + else if (MACH_O_MAGICS.has(magic(join(root, path)))) files.push(path) + } + for (const bundle of [...bundles].sort()) verifyMacOSNotarizedApplication(join(root, bundle), expected) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') let hits = 0 let misses = 0 diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f9348ec2eb..f881e0d48a 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -14,10 +14,11 @@ import { publishDesktopRelease } from './desktop-publish.ts' import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { desktopPublicationSummary } from './desktop-publication-summary.ts' import { publishDesktopTag } from './desktop-publish-tag.ts' +import { websiteApproval } from './publish-website-ci.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), - sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), + sourceRef: z.string(), channel: z.enum(['nightly', 'stable']), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) }) /** @@ -63,10 +64,14 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const env = process.env const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') + if (env.DESKTOP_UPLOAD_WEBSITE === 'true' && route.preset === 'production' + && !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || '')) { + throw new Error('desktop publish: configure DOWNLOAD_PROD_EO_ZONE_ID before approving website upload') + } if (mode === '--check') { - if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `website-destination=${createHash('sha256').update(`${config.bucket}/${plan.target}`).digest('hex')}\nwebsite-approval=${websiteApproval(manifest, config.bucket)}\napproval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_APPROVED_PACKAGE) await appendFile(env.GITHUB_STEP_SUMMARY, - await desktopPublicationSummary(manifest.source, plan, approval)) + await desktopPublicationSummary(manifest.source, plan, approval, env.DESKTOP_UPLOAD_WEBSITE === 'true')) if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) diff --git a/apps/desktop/scripts/publish-website-ci.ts b/apps/desktop/scripts/publish-website-ci.ts new file mode 100644 index 0000000000..bd3c32f517 --- /dev/null +++ b/apps/desktop/scripts/publish-website-ci.ts @@ -0,0 +1,130 @@ +/** Actions entrypoint for independently recoverable website upload and cache purge jobs. */ +import { createHash, randomUUID } from 'node:crypto' +import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { z } from 'zod' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { readRetainedDesktopManifest, retainedDesktopPath } from './desktop-release-store.ts' +import { prepareWebsiteInstaller, uploadWebsiteInstaller } from './desktop-website-installer.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { withDesktopChannelLock } from './desktop-publish-record.ts' +import { recoverDesktopPurge, type DesktopPurgeRecord } from './desktop-purge-recovery.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' + +const routeSchema = z.object({ runId: z.string().regex(/^[1-9]\d*$/u), attempt: z.string().regex(/^[1-9]\d*$/u), + commit: z.string().regex(/^[a-f0-9]{40}$/u), runnerName: z.string(), runnerLabel: z.string(), sourceRef: z.string(), + channel: z.enum(['nightly', 'stable']), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.literal('production') }) +const purgeSchema = z.object({ zoneId: z.string(), publicUrl: z.url(), submittedAt: z.iso.datetime(), + jobId: z.string().regex(/^[\w.-]{1,128}$/u).optional(), + status: z.enum(['pending', 'processing', 'success', 'failed', 'timeout', 'canceled', 'unknown']), + observedAt: z.iso.datetime().optional(), diagnostic: z.string().optional() }) +const stateSchema = z.object({ approval: z.string(), upload: z.enum(['pending', 'upload-intent', 'uploaded']), + uploadConfirmed: z.boolean().optional(), purge: purgeSchema.optional(), purgeAttempt: z.string().optional() }) + +/** + * Bind website approval to the retained manifest and exact production bucket. + * @param manifest Retained build metadata already matched to GitHub evidence. + * @param bucket Production COS destination. + * @returns Non-secret digest passed through the production approval job. + */ +export function websiteApproval(manifest: unknown, bucket: string): string { + return createHash('sha256').update(JSON.stringify({ manifest, bucket })).digest('hex') +} + +/** + * Upload a fixed installer or recover its cache purge, using a publication-run-owned journal. + * @param mode Explicit website operation; purge never opens or uploads installer bytes. + * @param env Actions identity, approved build and machine configuration. + * @returns Resolves on success or a prominently reported unknown purge result. + */ +export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.ProcessEnv): Promise { + const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) + if (env.RUNNER_NAME !== route.runnerName || !/^[1-9]\d*$/u.test(env.GITHUB_RUN_ID || '') + || !/^[1-9]\d*$/u.test(env.GITHUB_RUN_ATTEMPT || '')) throw new Error('website publication: invalid Actions identity or original machine') + const root = desktopReleasesRoot(env) + const manifest = await readRetainedDesktopManifest(root, route.runId, route.attempt) + const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, 'production') + const approval = websiteApproval(manifest, config.bucket) + if (!env.DESKTOP_WEBSITE_APPROVAL || env.DESKTOP_WEBSITE_APPROVAL !== approval) throw new Error('website publication: approved build or destination changed') + const zoneId = env.DOWNLOAD_PROD_EO_ZONE_ID + if (!zoneId || !/^zone-[a-z0-9]+$/u.test(zoneId)) throw new Error('website publication: configure DOWNLOAD_PROD_EO_ZONE_ID before uploading') + const directory = join(dirname(root), 'upload-records', `website-${env.GITHUB_RUN_ID}-${route.target}`) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const stateFile = join(directory, 'state.json') + let state: z.infer = { approval, upload: 'pending' } + try { state = stateSchema.parse(JSON.parse(await readFile(stateFile, 'utf8'))) } + catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error } + if (state.approval !== approval) throw new Error('website publication: journal belongs to another approved build') + const save = async () => { + const temporary = join(directory, `.state-${randomUUID()}.json`) + await writeFile(temporary, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await rename(temporary, stateFile) + } + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${directory}\n`) + const credentials = await readDesktopUploadCredentials(config) + if (mode === 'upload') { + const installer = await prepareWebsiteInstaller(retainedDesktopPath(root, route.runId, route.attempt), manifest, route, + env.GITHUB_REPOSITORY || '', config.bucket) + try { + const outcome = await withDesktopChannelLock(join(dirname(root), 'publish-locks'), `${config.bucket}/${installer.plan.artifacts[0]!.key}`, async () => { + const previouslyUploaded = state.uploadConfirmed === true || state.upload === 'uploaded' + return uploadWebsiteInstaller(installer, credentials, desktopPublishCos(installer.plan, credentials), async (event) => { + state = event === 'upload-intent' + ? { approval: state.approval, upload: event, uploadConfirmed: previouslyUploaded } + : { ...state, upload: event, uploadConfirmed: true } + await save() + }, { previouslyUploaded }) + }) + const disposition = outcome === 'present' ? 'identical remote bytes and download filename confirmed; skipped' : 'uploaded' + const text = `Website installer ${disposition}: ${installer.plan.publicUrl}; version ${installer.plan.version}\n` + process.stdout.write(text) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, text) + } finally { await installer.handle.close() } + return + } + if (state.upload !== 'uploaded') throw new Error('website publication: upload completion is not confirmed; recover the upload job first') + const { desktopWebsiteInstallerUrl } = await import('./desktop-auto-update-environment.mjs') + const publicUrl = desktopWebsiteInstallerUrl(route.target) + const { teo } = await import('tencentcloud-sdk-nodejs-teo') + const client = new teo.v20220901.Client({ credential: credentials, region: '', + profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } } }) + const previousAttempt = state.purgeAttempt + const result = await recoverDesktopPurge({ ...credentials, zoneId, publicUrl }, state.purge, client, { + intervalMs: 10_000, waitMs: 300_000, + allowResubmit: previousAttempt !== undefined && previousAttempt !== env.GITHUB_RUN_ATTEMPT, + now: Date.now, sleep, + save: async (purge: DesktopPurgeRecord) => { + const accepted = purge.jobId && purge.jobId !== state.purge?.jobId + state = { ...state, purge, purgeAttempt: env.GITHUB_RUN_ATTEMPT } + await save() + if (accepted) process.stdout.write(`Website cache purge accepted: ${publicUrl}; task ${purge.jobId}\n`) + }, + }) + const details = `${publicUrl}\nTask: ${result.jobId ?? 'unknown'}\nObserved: ${result.observedAt}\n${result.diagnostic ?? ''}\n` + if (result.status === 'unknown') { + const acceptance = result.jobId ? '刷新已提交,执行结果未确认' : '刷新提交未确认,可能尚未提交' + process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传;${acceptance}。请核对 EdgeOne 任务记录后人工恢复。\n${details}`) + if (env.GITHUB_STEP_SUMMARY) { + let existing = '' + try { existing = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') } + catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error } + await writeFile(env.GITHUB_STEP_SUMMARY, `# ⚠ 官网资源已上传,缓存刷新结果未确认\n\n${acceptance}\n\n${details}${existing ? `\n${existing}` : ''}`) + } + } else { + process.stdout.write(`Website cache purge completed\n${details}`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, `Website cache purge completed\n\n${details}`) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + try { + const mode = process.argv[2] + if (process.argv.length !== 3 || (mode !== 'upload' && mode !== 'purge')) throw new Error('Usage: publish-website-ci.ts upload|purge') + await publishWebsiteCi(mode, process.env) + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'website publication'))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index 152369c49b..d1ad2264d4 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -8,11 +8,11 @@ import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop- const positiveId = z.string().regex(/^[1-9]\d*$/u) const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() }) const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(), - head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'), + head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.string(), event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)), repository: z.object({ full_name: z.string() }) }) const jobsSchema = z.object({ jobs: z.array(z.object({ - name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(), + name: z.string(), run_attempt: z.number().int().positive(), runner_name: z.string().nullable(), conclusion: z.string().nullable(), })) }) /** @@ -29,12 +29,29 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r const inputs = inputsSchema.parse(inputsValue) const base = `repos/${repository}/actions/runs/${inputs.package_run_id}` const attempt = inputs.package_run_attempt || String(z.object({ run_attempt: z.number().int().positive() }).parse(api(base)).run_attempt) - const run = runSchema.parse(api(`${base}/attempts/${attempt}`)) + let run = runSchema.parse(api(`${base}/attempts/${attempt}`)) if (String(run.id) !== inputs.package_run_id || String(run.run_attempt) !== attempt || run.repository.full_name !== repository) { throw new Error('desktop publish: selected run or attempt does not match GitHub evidence') } - const jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) - const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name) + let jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) + let packages = jobs.filter(job => job.name === 'package') + if (packages.length === 0 || packages.every(job => job.run_attempt < Number(attempt))) { + // An archival-only attempt owns no new build; select the latest actual package job, never a prior success over a failed build. + const history = z.array(jobsSchema).parse(api(`${base}/jobs?filter=all&per_page=100`, true)).flatMap(page => page.jobs) + .filter(job => job.name === 'package' && job.run_attempt <= Number(attempt)) + .sort((left, right) => right.run_attempt - left.run_attempt) + const original = history[0] + if (!original || original.conclusion !== 'success') throw new Error('desktop publish: latest original package job was not successful') + const selected = run + run = runSchema.parse(api(`${base}/attempts/${original.run_attempt}`)) + if (String(run.id) !== inputs.package_run_id || run.run_attempt !== original.run_attempt || run.repository.full_name !== repository + || run.head_sha !== selected.head_sha || run.path !== selected.path) { + throw new Error('desktop publish: original build identity differs from GitHub evidence') + } + jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${original.run_attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) + packages = jobs.filter(job => job.name === 'package') + } + packages = packages.filter(job => job.run_attempt === run.run_attempt && job.conclusion === 'success' && job.runner_name) if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job') const runnerName = packages[0]!.runner_name! const routes = desktopPublishRunnersSchema.parse(routesValue) diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts index 69bade9712..e672274992 100644 --- a/apps/desktop/scripts/retain-desktop-ci.ts +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -6,6 +6,7 @@ import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDeskt import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' import { validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { desktopPublishRunnersSchema } from './desktop-publish-targets.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' function required(env: NodeJS.ProcessEnv, name: string): string { @@ -26,6 +27,22 @@ export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string { return root } +/** + * Resolve the original machine's unique label before expensive packaging work. + * @param env Machine identity, target and administrator-owned runner mapping. + * @returns The exact label used by the later GitHub archival job. + */ +export function resolveDesktopRetentionRunner(env: NodeJS.ProcessEnv): string { + const target = resolveDesktopBuildTarget(env) + const routes = desktopPublishRunnersSchema.parse(JSON.parse(required(env, 'DESKTOP_PUBLISH_RUNNERS'))) + const route = routes[required(env, 'RUNNER_NAME')] + if (!route || !route.target.includes(target) + || new Set(Object.values(routes).map(item => item.label)).size !== Object.keys(routes).length) { + throw new Error('desktop retention: original machine requires a unique artifact routing label') + } + return route.label +} + /** * Save the complete installer inventory for a successful package job, without reading signing secrets. * @param env Actions identity, resolved package version and machine preset selection. @@ -34,6 +51,7 @@ export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string { */ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(import.meta.dirname, '..')): Promise { const target = resolveDesktopBuildTarget(env) + const runnerLabel = resolveDesktopRetentionRunner(env) const windows = target === 'win-x64' const platform = windows ? 'win32' : 'darwin' const settings = loadDesktopPackageEnvironment(platform, env, appRoot, { unsigned: true }) @@ -66,7 +84,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel)) } const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) - if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\nrunner=${runnerLabel}\nrunner-name=${source.runnerName}\nattempt=${source.attempt}\n`) const nextStep = desktopPublishQualificationError(qualification) ?? `Publish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}` if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, @@ -75,5 +93,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { - await retainDesktopCi(process.env) + if (process.argv[2] === '--check-runner' && process.argv.length === 3) resolveDesktopRetentionRunner(process.env) + else if (process.argv.length === 2) await retainDesktopCi(process.env) + else throw new Error('Usage: retain-desktop-ci.ts [--check-runner]') } diff --git a/apps/desktop/scripts/test-update-channels.mjs b/apps/desktop/scripts/test-update-channels.mjs new file mode 100644 index 0000000000..22934e9c8c --- /dev/null +++ b/apps/desktop/scripts/test-update-channels.mjs @@ -0,0 +1,29 @@ +/** Qualify built channel transitions with Electron and the pinned updater, without installing. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import electron from 'electron' +import { recordVerifiedVersions } from '../tests/updates/electron-updater/versions.mjs' + +if (!['win32', 'darwin'].includes(process.platform)) throw new Error('Qualification requires Windows or macOS') +if (process.argv.slice(2).some(arg => arg !== '--record')) throw new Error('Usage: test-update-channels.mjs [--record]') + +const root = await mkdtemp(join(tmpdir(), 'desktop-channel-')) +const environment = { ...process.env, DSH_CHANNEL_TEST_ROOT: root } +delete environment.ELECTRON_RUN_AS_NODE +const child = spawn(electron, [fileURLToPath(new URL('../tests/updates/electron-updater/qualification.mjs', import.meta.url))], { + env: environment, stdio: 'inherit', windowsHide: true, +}) +let timedOut = false +const deadline = setTimeout(() => { timedOut = true; child.kill() }, 30_000) +try { + const code = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => signal ? reject(new Error(`Channel qualification ended: timedOut=${timedOut}, signal=${signal}`)) : resolve(code)) + }) + if (timedOut) throw new Error('Channel qualification timed out after 30000 ms') + if (code !== 0) throw new Error(`Channel qualification failed: ${code}`) + if (process.argv.includes('--record')) await recordVerifiedVersions() +} finally { clearTimeout(deadline); await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } diff --git a/apps/desktop/scripts/windows-build-workspace.d.mts b/apps/desktop/scripts/windows-build-workspace.d.mts deleted file mode 100644 index 92f3a3c704..0000000000 --- a/apps/desktop/scripts/windows-build-workspace.d.mts +++ /dev/null @@ -1,19 +0,0 @@ -/** Identity of a Windows packaging run and its independent build root. */ -export interface WindowsBuildWorkspaceOptions { - sourceDirectory: string - buildRoot: string - runId: string - runAttempt: string - sourceSha: string -} -/** - * Clone the clean selected commit into a new run-owned directory. - * @param options - Original checkout and Actions run identity. - * @returns Checkout and temporary directory owned by this run. - */ -export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string } -/** - * Remove the directory only when its ownership record matches the run. - * @param options - Original checkout and Actions run identity. - */ -export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs deleted file mode 100644 index 977dda6618..0000000000 --- a/apps/desktop/scripts/windows-build-workspace.mjs +++ /dev/null @@ -1,96 +0,0 @@ -/** Create and remove a run-owned checkout for Windows packaging. */ -import { execFileSync } from 'node:child_process' -import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, isAbsolute, join, parse, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' - -function assertUnlinkedAncestors(directory) { - for (let current = directory; ; current = dirname(current)) { - if (existsSync(current) && lstatSync(current).isSymbolicLink()) { - throw new Error('Packaging workspace ancestors must not be links') - } - if (dirname(current) === current) break - } -} - -function locations(options) { - const { buildRoot, runId, runAttempt, sourceSha } = options - if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) { - throw new Error('Packaging build root must be a nonempty absolute directory') - } - const root = resolve(buildRoot) - if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root') - if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) { - throw new Error('Packaging workspace requires a run identity and exact commit') - } - assertUnlinkedAncestors(root) - for (let current = root; ; current = dirname(current)) { - if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts') - if (dirname(current) === current) break - } - const owner = join(root, `windows-package-${runId}-${runAttempt}`) - assertUnlinkedAncestors(owner) - return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') } -} - -function git(directory, args) { - return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() -} - -function ownership(options, paths) { - return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha }) -} - -/** - * Clone the validated commit into a new run-owned checkout; failures retain diagnostics. - * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity. - * @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run. - */ -export function prepareWindowsBuildWorkspace(options) { - const paths = locations(options) - if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) { - throw new Error('Packaging source must be clean and match the selected commit') - } - mkdirSync(paths.root, { recursive: true }) - mkdirSync(paths.owner) - writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' }) - mkdirSync(paths.temporaryDirectory) - git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory]) - git(paths.directory, ['checkout', '--detach', options.sourceSha]) - if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) { - throw new Error('Isolated packaging checkout does not match the selected commit') - } - return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory } -} - -/** - * Remove only the matching run-owned directory after successful artifact upload. - * Transient filesystem failures retry up to three times; persistent failures propagate. - * @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity. - * @returns {void} - */ -export function removeWindowsBuildWorkspace(options) { - const paths = locations(options) - const marker = join(paths.owner, 'owner.json') - const stat = lstatSync(marker) - if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) { - throw new Error('Packaging workspace ownership does not match this run') - } - rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 }) -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - const options = { - sourceDirectory: process.env.GITHUB_WORKSPACE ?? '', - buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '', - runId: process.env.GITHUB_RUN_ID ?? '', - runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '', - sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '', - } - if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options) - else { - if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments') - const result = prepareWindowsBuildWorkspace(options) - appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`) - } -} diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 862b8f81ed..19ae3fe796 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -80,10 +80,10 @@ export interface DesktopAccountBackend { * Connect native account operations to the standard authenticated Web backend. * @param origin - Host Web origin. * @param invoke - validated unary RPC caller. - * @param cookies - Electron session cookie reader. + * @param cookie - Host authentication Cookie header for the account WebSocket. * @returns account operations; watch callers own their subscriptions. */ -export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise): DesktopAccountBackend { +export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookie: string): DesktopAccountBackend { const call = async (method: string, args: Record = {}): Promise => accountView(await invoke({ namespace: 'account', method, args })) return { @@ -98,34 +98,31 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo const streamId = randomUUID() const expiryStreamId = randomUUID() const analyticsPolicyStreamId = randomUUID() - void cookies().then((cookie) => { - if (closed) return - const url = new URL(REMOTE_STREAM_MUX_PATH, origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) - socket.on('open', () => { - if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) - socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) - socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) - }) - socket.on('message', (data) => { - try { - const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) - const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) - if (frame.streamId === analyticsPolicyStreamId) { - if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) - else onAnalyticsEnabledChanged?.(false) - return - } - if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } - if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') - if (frame.type === 'item') listener(accountView(frame.value)) - else socket?.close() - } catch { socket?.close() } - }) - socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) + const url = new URL(REMOTE_STREAM_MUX_PATH, origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) + socket.on('open', () => { + if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) + }) + socket.on('message', (data) => { + try { + const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) + const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === analyticsPolicyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) + else onAnalyticsEnabledChanged?.(false) + return + } + if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } + if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') + if (frame.type === 'item') listener(accountView(frame.value)) + else socket?.close() + } catch { socket?.close() } + }) + socket.on('error', () => { socket?.close() }) + socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() } diff --git a/apps/desktop/src/file-actions.ts b/apps/desktop/src/file-actions.ts new file mode 100644 index 0000000000..45c4a25fc1 --- /dev/null +++ b/apps/desktop/src/file-actions.ts @@ -0,0 +1,192 @@ +/** Trusted-window Chromium downloads and clipboard fallback reads. */ +import { app, ipcMain, session } from 'electron' +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { closeSync, openSync, rmSync } from 'node:fs' +import { extname, join } from 'node:path' +import { resolveDragFileName } from '@deepseek-ai/dsh-util-file-drag' +import { randomUUID } from 'node:crypto' +import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' +import { DESKTOP_IPC } from './ipc.ts' +import { resolveDurationMs } from './duration-env.ts' + +/** @param value - untrusted IPC input. @returns captured URL and destination policy. */ +function saveRequest(value: unknown): { url: URL; name?: string; mediaType?: string; mode: 'download' | 'saveAs' } { + if (typeof value !== 'object' || value === null + || !('url' in value) || typeof value.url !== 'string' + || ('name' in value && typeof value.name !== 'string') + || ('mediaType' in value && typeof value.mediaType !== 'string') + || !('mode' in value) || (value.mode !== 'download' && value.mode !== 'saveAs')) throw new Error('Invalid file save request') + const url = new URL(value.url) + if (!['https:', 'http:', 'blob:', 'data:'].includes(url.protocol) || url.username || url.password) throw new Error('Unsupported file source') + return { url, mode: value.mode, + ...'name' in value ? { name: value.name as string } : {}, + ...'mediaType' in value ? { mediaType: value.mediaType as string } : {}, + } +} + +/** + * Install trusted-window original-file operations. + * @param assertOwner - rejects shell pages, subframes, and unowned windows. + * @param hostUrl - current application Host excluded from image reads. + */ +export function installFileActions( + assertOwner: (event: IpcMainInvokeEvent) => BrowserWindow, hostUrl: () => string | undefined = () => undefined, +): void { + const timeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS', 30_000) + const maxBytes = Number(process.env.DSH_DESKTOP_IMAGE_READ_MAX_BYTES ?? 64 * 1024 * 1024) + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('DSH_DESKTOP_IMAGE_READ_MAX_BYTES must be a positive integer') + const pending = new Map + finish: (result: FileSaveResult | Error) => void + started: () => void + item?: Electron.DownloadItem + }>() + session.defaultSession.on('will-download', (_event, item, contents) => { + const url = item.getURLChain()[0] + if (url === undefined || !new URL(url).hash.startsWith('#dsh-download=')) return + const entry = pending.get(url) + if (entry === undefined || entry.owner.webContents !== contents) { item.cancel(); return } + entry.started() + entry.item = item + let reserved: string | undefined + try { + assertOwner(entry.event) + const name = resolveDragFileName({ + name: entry.request.name ?? item.getFilename(), + mediaType: item.getMimeType() || entry.request.mediaType || '', + }) + let destination = join(app.getPath('downloads'), name) + if (entry.request.mode === 'saveAs') { + item.setSaveDialogOptions({ defaultPath: destination, properties: ['showOverwriteConfirmation', 'createDirectory'] }) + } else { + const extension = extname(destination) + const stem = destination.slice(0, destination.length - extension.length) + for (let index = 0; ; index++) { + destination = index === 0 ? `${stem}${extension}` : `${stem} (${index})${extension}` + try { closeSync(openSync(destination, 'wx')); break } + catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') continue + throw error + } + } + reserved = destination + item.setSavePath(destination) + } + item.once('done', (_event, state) => { + try { + if (state !== 'completed') { + if (reserved !== undefined) rmSync(reserved, { force: true }) + entry.finish(state === 'cancelled' ? { status: 'cancelled' } : new Error('File download interrupted')) + return + } + if (!pending.has(url)) return + assertOwner(entry.event) + entry.finish({ status: 'saved', path: item.getSavePath() }) + } catch (error) { entry.finish(error instanceof Error ? error : new Error('File download unavailable')) } + }) + } catch (error) { + item.cancel() + try { if (reserved !== undefined) rmSync(reserved, { force: true }) } + catch (cleanupError) { + entry.finish(new AggregateError([error, cleanupError], 'File download failed and its partial file could not be removed')) + return + } + entry.finish(error instanceof Error ? error : new Error('File download unavailable')) + } + }) + const downloadTimeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', 300_000) + ipcMain.handle(DESKTOP_IPC.fileSave, (event, value: unknown): Promise => { + const owner = assertOwner(event) + const request = saveRequest(value) + // Fragments do not change the fetched resource; each native request has its own completion identity. + request.url.hash = `dsh-download=${randomUUID()}` + const url = request.url.href + return new Promise((resolve, reject) => { + const contents = owner.webContents + const finish = (result: FileSaveResult | Error): void => { + if (!pending.delete(url)) return + clearTimeout(timer) + owner.removeListener('closed', cancel) + contents.removeListener('did-start-navigation', navigate) + if (result instanceof Error) reject(result) + else resolve(result) + } + const cancel = (): void => { + const item = pending.get(url)?.item + finish({ status: 'cancelled' }) + item?.cancel() + } + const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame && !inPlace) cancel() + } + const timer = setTimeout(() => { + const item = pending.get(url)?.item + finish(new Error('File download timed out')) + item?.cancel() + }, downloadTimeoutMs) + pending.set(url, { event, owner, request, finish, started: () => { clearTimeout(timer) } }) + owner.once('closed', cancel) + contents.on('did-start-navigation', navigate) + try { contents.downloadURL(url) } + catch (error) { finish(error instanceof Error ? error : new Error('File download unavailable')) } + }) + }) + ipcMain.handle(DESKTOP_IPC.clipboardImageRead, async (event, value: unknown) => { + const owner = assertOwner(event) + if (typeof value !== 'string') throw new Error('Invalid image URL') + const checkedUrl = (value: string): URL => { + const url = new URL(value) + const host = hostUrl() + const application = host === undefined ? undefined : new URL(host) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password + || application !== undefined && url.port === application.port + && (url.hostname === application.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('Invalid image URL') + } + return url + } + let url = checkedUrl(value) + const contents = owner.webContents + const lifetime = new AbortController() + const abort = (): void => { lifetime.abort() } + owner.once('closed', abort) + const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => { + if (mainFrame && !inPlace) abort() + } + contents.on('did-start-navigation', navigate) + try { + const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(timeoutMs)]) + let response: Response + for (let redirects = 0; ; redirects++) { + response = await fetch(url, { signal, redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer' }) + if (![301, 302, 303, 307, 308].includes(response.status)) break + await response.body?.cancel() + const location = response.headers.get('location') + if (redirects >= 20 || location === null) throw new Error('Invalid image redirect') + url = checkedUrl(new URL(location, url).href) + } + if (!response.ok) throw new Error('Original image unavailable') + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('Original image unavailable') + const chunks: Uint8Array[] = [] + let size = 0 + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + size += chunk.value.byteLength + if (size > maxBytes) throw new Error('Original image exceeds the download limit') + chunks.push(chunk.value) + } + } finally { await reader.cancel(); reader.releaseLock() } + assertOwner(event) + lifetime.signal.throwIfAborted() + return { bytes: new Uint8Array(Buffer.concat(chunks)), mediaType: response.headers.get('content-type') ?? '' } + } finally { + owner.removeListener('closed', abort) + contents.removeListener('did-start-navigation', navigate) + } + }) +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index e62a84f0a1..83ebe7415a 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,5 +1,8 @@ +import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Typed preload operations exposed only by the Electron shell. */ +export type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import type { DesktopChannelBridge, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { IpcMainInvokeEvent } from 'electron' import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' @@ -7,6 +10,8 @@ import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { + fileSave: 'dsh-desktop:file-save', + clipboardImageRead: 'dsh-desktop:clipboard-image-read', shortcutsInput: 'dsh-desktop:shortcuts-input', shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window', shortcutsGet: 'dsh-desktop:shortcuts-get', @@ -29,6 +34,9 @@ export const DESKTOP_IPC = { deviceInfo: 'dsh-desktop:device-info', localeBootstrap: 'dsh-desktop:locale-bootstrap', localeChanged: 'dsh-desktop:locale-changed', + updatesChannelGet: 'dsh-desktop:updates-channel-get', + updatesChannelSet: 'dsh-desktop:updates-channel-set', + updatesChannelChanged: 'dsh-desktop:updates-channel-changed', updatesStatus: 'dsh-desktop:updates-status', updatesOpen: 'dsh-desktop:updates-open', updatesPresentation: 'dsh-desktop:updates-presentation', @@ -48,17 +56,23 @@ export interface DesktopUpdateState { /** Main-owned diagnostics without subprocess output or credentials; hidden until expanded. */ readonly technicalDetails?: string readonly percent?: number - readonly failedOperation?: 'check' | 'download' | 'install' + readonly failedOperation?: 'check' | 'download' | 'install' | 'configuration' /** Main-owned preparation cause; UI wording is selected by the active locale. */ readonly preparationFailure?: DesktopUpdatePreparationFailureKind + /** Current filesystem failure, independent of previous cache cleanup attempts. */ + readonly downloadFailure?: 'space' | 'permission' | 'busy' } /** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */ export type DesktopUpdateFailureKind = + | 'configuration' | 'check' | 'check-network' | 'download' | 'download-network' + | 'download-space' + | 'download-permission' + | 'download-busy' | 'install' | 'install-network' | 'stop-failed' @@ -72,10 +86,12 @@ export interface DesktopUpdatePresentation { readonly version?: string readonly percent?: number readonly failure?: DesktopUpdateFailureKind + readonly configurationError?: DesktopChannelState['error'] } /** Product documents cannot supply update versions, package URLs, or installation authorization. */ export interface DshDesktopProductApi { + readonly fileActions: DesktopFileActions readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge readonly fileDrag: DesktopFileDrag @@ -87,6 +103,7 @@ export interface DshDesktopProductApi { */ deviceInfo(): Promise readonly updates: { + readonly channels: DesktopChannelBridge status(): Promise open(): Promise subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 74a1f19941..bddf39b9f8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -104,10 +104,22 @@ export const en = { welcomeContinueFailed: 'Could not open the workspace. Please try again.', checkUpdatesMenu: 'Check for Updates…', reloadPageMenu: 'Reload Page', + toggleDevToolsMenu: 'Developer tools', restartAppHostMenu: 'Restart App and Host', + updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.', + updateConfigurationRead: 'Settings file error; updates are paused', + updateConfigurationFormat: 'Settings file error; updates are paused', + updateConfigurationChannel: 'Settings file error; updates are paused', + updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app', + updateReloadSettings: 'Reload settings', + updateConfigurationDetails: 'Review settings', + updateShowSettingsFile: 'Show settings file', updateCheckFailedTitle: 'Update Check Failed', updateCheckFailed: 'Could not check for updates. Please try again later.', updateDownloadFailed: 'Could not download the update. Please try again.', + updateDownloadSpaceFailed: 'Not enough disk space to download the update. Free up space, then retry.', + updateDownloadPermissionFailed: 'Cannot write the update files. Check the folder permissions and retry.', + updateDownloadBusyFailed: 'An update file is in use. Close the program using it, then retry.', updateInstallFailed: 'Could not install the update. Please try again later.', updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.', updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.', @@ -283,10 +295,22 @@ export const zh = { welcomeContinueFailed: '无法打开工作区,请重试。', checkUpdatesMenu: '检查更新…', reloadPageMenu: '刷新页面', + toggleDevToolsMenu: '开发者工具', restartAppHostMenu: '重启应用与 Host', + updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。', + updateConfigurationRead: '配置文件异常,更新已暂停', + updateConfigurationFormat: '配置文件异常,更新已暂停', + updateConfigurationChannel: '配置文件异常,更新已暂停', + updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用', + updateReloadSettings: '重新读取配置', + updateConfigurationDetails: '查看配置', + updateShowSettingsFile: '显示配置文件', updateCheckFailedTitle: '更新检查失败', updateCheckFailed: '检查更新失败,请稍后重试。', updateDownloadFailed: '下载更新失败,请重试。', + updateDownloadSpaceFailed: '磁盘空间不足,无法下载更新。请释放空间后重试。', + updateDownloadPermissionFailed: '无法写入更新文件,请检查目录权限后重试。', + updateDownloadBusyFailed: '更新文件正被占用,请关闭占用它的程序后重试。', updateInstallFailed: '安装更新失败,请稍后重试。', updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。', updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。', @@ -460,10 +484,22 @@ export const ja = { welcomeContinueFailed: 'ワークスペースを開けませんでした。もう一度お試しください。', checkUpdatesMenu: 'アップデートを確認…', reloadPageMenu: 'ページを再読み込み', + toggleDevToolsMenu: '開発者ツール', restartAppHostMenu: 'アプリとホストを再起動', + updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。', + updateConfigurationRead: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationFormat: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationChannel: '設定ファイルに問題があるため、更新を停止しています', + updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください', + updateReloadSettings: '設定を再読み込み', + updateConfigurationDetails: '設定を確認', + updateShowSettingsFile: '設定ファイルを表示', updateCheckFailedTitle: 'アップデートの確認に失敗しました', updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。', updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。', + updateDownloadSpaceFailed: '更新のダウンロードに必要なディスク容量が不足しています。空き容量を増やして再試行してください。', + updateDownloadPermissionFailed: '更新ファイルを書き込めません。フォルダーのアクセス権を確認して再試行してください。', + updateDownloadBusyFailed: '更新ファイルが使用中です。使用しているプログラムを閉じて再試行してください。', updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。', updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。', updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。', @@ -637,10 +673,22 @@ export const es = { welcomeContinueFailed: 'No se pudo abrir el espacio de trabajo. Por favor inténtalo de nuevo.', checkUpdatesMenu: 'Buscar actualizaciones...', reloadPageMenu: 'Recargar página', + toggleDevToolsMenu: 'Herramientas de desarrollo', restartAppHostMenu: 'Reiniciar la aplicación y el host', + updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.', + updateConfigurationRead: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationFormat: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationChannel: 'Error en el archivo de ajustes; actualizaciones pausadas', + updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación', + updateReloadSettings: 'Volver a cargar ajustes', + updateConfigurationDetails: 'Revisar ajustes', + updateShowSettingsFile: 'Mostrar archivo de ajustes', updateCheckFailedTitle: 'Error en la comprobación de actualización', updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.', updateDownloadFailed: 'No se pudo descargar la actualización. Por favor inténtalo de nuevo.', + updateDownloadSpaceFailed: 'No hay espacio suficiente para descargar la actualización. Libera espacio y vuelve a intentarlo.', + updateDownloadPermissionFailed: 'No se pueden escribir los archivos de actualización. Revisa los permisos de la carpeta y vuelve a intentarlo.', + updateDownloadBusyFailed: 'Un archivo de actualización está en uso. Cierra el programa que lo usa y vuelve a intentarlo.', updateInstallFailed: 'No se pudo instalar la actualización. Inténtelo de nuevo más tarde.', updateCheckNetworkFailed: 'No se pudo buscar actualizaciones. Comprueba tu conexión y vuelve a intentarlo.', updateDownloadNetworkFailed: 'No se pudo descargar la actualización. Comprueba tu conexión y vuelve a intentarlo.', @@ -814,10 +862,22 @@ export const fr = { welcomeContinueFailed: "Impossible d'ouvrir l'espace de travail. Veuillez réessayer.", checkUpdatesMenu: 'Vérifiez les mises à jour…', reloadPageMenu: 'Recharger la page', + toggleDevToolsMenu: 'Outils de développement', restartAppHostMenu: "Redémarrez l'application et l'hôte", + updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.', + updateConfigurationRead: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationFormat: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationChannel: 'Erreur du fichier de paramètres ; mises à jour suspendues', + updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application', + updateReloadSettings: 'Recharger les paramètres', + updateConfigurationDetails: 'Vérifier les paramètres', + updateShowSettingsFile: 'Afficher le fichier de paramètres', updateCheckFailedTitle: 'Échec de la vérification de la mise à jour', updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.', updateDownloadFailed: 'Impossible de télécharger la mise à jour. Veuillez réessayer.', + updateDownloadSpaceFailed: 'Espace disque insuffisant pour télécharger la mise à jour. Libérez de l’espace, puis réessayez.', + updateDownloadPermissionFailed: 'Impossible d’écrire les fichiers de mise à jour. Vérifiez les autorisations du dossier, puis réessayez.', + updateDownloadBusyFailed: 'Un fichier de mise à jour est utilisé. Fermez le programme qui l’utilise, puis réessayez.', updateInstallFailed: "Impossible d'installer la mise à jour. Veuillez réessayer plus tard.", updateCheckNetworkFailed: 'Impossible de vérifier les mises à jour. Vérifiez votre connexion et réessayez.', updateDownloadNetworkFailed: 'Impossible de télécharger la mise à jour. Vérifiez votre connexion et réessayez.', @@ -991,10 +1051,22 @@ export const de = { welcomeContinueFailed: 'Der Arbeitsbereich konnte nicht geöffnet werden. Bitte versuchen Sie es erneut.', checkUpdatesMenu: 'Nach Updates suchen…', reloadPageMenu: 'Seite neu laden', + toggleDevToolsMenu: 'Entwicklertools', restartAppHostMenu: 'Anwendung und Host neu starten', + updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.', + updateConfigurationRead: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationFormat: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationChannel: 'Fehler in der Einstellungsdatei; Updates pausiert', + updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten', + updateReloadSettings: 'Einstellungen neu laden', + updateConfigurationDetails: 'Einstellungen prüfen', + updateShowSettingsFile: 'Einstellungsdatei anzeigen', updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen', updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.', updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.', + updateDownloadSpaceFailed: 'Nicht genügend Speicherplatz für das Update. Geben Sie Speicherplatz frei und versuchen Sie es erneut.', + updateDownloadPermissionFailed: 'Update-Dateien können nicht geschrieben werden. Prüfen Sie die Ordnerberechtigungen und versuchen Sie es erneut.', + updateDownloadBusyFailed: 'Eine Update-Datei wird verwendet. Schließen Sie das verwendende Programm und versuchen Sie es erneut.', updateInstallFailed: 'Das Update konnte nicht installiert werden. Bitte versuchen Sie es später noch einmal.', updateCheckNetworkFailed: 'Es konnte nicht nach Updates gesucht werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', updateDownloadNetworkFailed: 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.', @@ -1168,10 +1240,22 @@ export const ru = { welcomeContinueFailed: 'Не удалось открыть рабочую область. Пожалуйста, попробуйте еще раз.', checkUpdatesMenu: 'Проверить наличие обновлений…', reloadPageMenu: 'Обновить страницу', + toggleDevToolsMenu: 'Инструменты разработчика', restartAppHostMenu: 'Перезапустите приложение и хост.', + updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.', + updateConfigurationRead: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationFormat: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationChannel: 'Ошибка файла настроек; обновления приостановлены', + updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение', + updateReloadSettings: 'Перечитать настройки', + updateConfigurationDetails: 'Проверить настройки', + updateShowSettingsFile: 'Показать файл настроек', updateCheckFailedTitle: 'Проверка обновления не удалась', updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.', updateDownloadFailed: 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.', + updateDownloadSpaceFailed: 'Недостаточно места для загрузки обновления. Освободите место и повторите попытку.', + updateDownloadPermissionFailed: 'Не удалось записать файлы обновления. Проверьте права доступа к папке и повторите попытку.', + updateDownloadBusyFailed: 'Файл обновления занят. Закройте использующую его программу и повторите попытку.', updateInstallFailed: 'Не удалось установить обновление. Пожалуйста, повторите попытку позже.', updateCheckNetworkFailed: 'Не удалось проверить наличие обновлений. Проверьте подключение и повторите попытку.', updateDownloadNetworkFailed: 'Не удалось загрузить обновление. Проверьте подключение и повторите попытку.', @@ -1345,10 +1429,22 @@ export const pt = { welcomeContinueFailed: 'Não foi possível abrir o espaço de trabalho. Por favor, tente novamente.', checkUpdatesMenu: 'Verifique se há atualizações…', reloadPageMenu: 'Recarregar página', + toggleDevToolsMenu: 'Ferramentas do desenvolvedor', restartAppHostMenu: 'Reinicie o aplicativo e o host', + updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.', + updateConfigurationRead: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationFormat: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationChannel: 'Erro no arquivo de configurações; atualizações pausadas', + updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo', + updateReloadSettings: 'Recarregar configurações', + updateConfigurationDetails: 'Verificar configurações', + updateShowSettingsFile: 'Mostrar arquivo de configurações', updateCheckFailedTitle: 'Falha na verificação de atualização', updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.', updateDownloadFailed: 'Não foi possível baixar a atualização. Por favor, tente novamente.', + updateDownloadSpaceFailed: 'Não há espaço suficiente para baixar a atualização. Libere espaço e tente novamente.', + updateDownloadPermissionFailed: 'Não foi possível gravar os arquivos de atualização. Verifique as permissões da pasta e tente novamente.', + updateDownloadBusyFailed: 'Um arquivo de atualização está em uso. Feche o programa que o utiliza e tente novamente.', updateInstallFailed: 'Não foi possível instalar a atualização. Por favor, tente novamente mais tarde.', updateCheckNetworkFailed: 'Não foi possível verificar se há atualizações. Verifique sua conexão e tente novamente.', updateDownloadNetworkFailed: 'Não foi possível baixar a atualização. Verifique sua conexão e tente novamente.', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4770fe83bf..1a2488fe9d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,12 @@ -import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' -import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' /** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ +import { installFileActions } from './file-actions.ts' +import { configurationFailure, channelSaveFailure, configurationMessage } from './update-configuration.ts' +import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import { DesktopUpdateSettings } from './update-settings.ts' +import { isDesktopUpdateChannel } from './settings-file.ts' +import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types' +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' import { access, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,7 +20,6 @@ import { powerMonitor, nativeImage, nativeTheme, - net, protocol, session, shell, @@ -38,7 +42,7 @@ import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { prepareDesktopUpdateWindow } from './update-window.ts' import { DesktopCommandManager } from './command-management.ts' -import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' +import { serveWebDocument, authenticateWebHost, forwardWebRequest, removeLegacyHostCookies } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts' import { OperationJournal } from './operation-journal.ts' @@ -365,7 +369,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi } async function main(): Promise { - const desktopSettings = readDesktopSettings(app.getPath('userData')) + const desktopSettings = await readDesktopSettings(app.getPath('userData')) void pruneCrashReports(app.getPath('logs')) const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion()) @@ -421,6 +425,9 @@ async function main(): Promise { let updateStoppedHost = false let updateStopFailure: DesktopHostUncleanExitError | undefined let updateState: DesktopUpdateState = { phase: 'idle' } + let channelState: DesktopChannelState | undefined + const updatePresentation = (state: DesktopUpdateState) => ({ ...presentDesktopUpdate(state), + ...(channelState?.error === undefined ? {} : { configurationError: channelState.error }) }) const systemLanguages = app.getPreferredSystemLanguages() let locale = windowsLanguage === undefined ? resolveDesktopStartupLocale(null, systemLanguages) : resolveDesktopLocale(windowsLanguage) @@ -442,11 +449,11 @@ async function main(): Promise { const updateOverlays = new DesktopUpdateOverlays() const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays) const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true - const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise => { + const ordinaryMessageBox = async (options: UpdateDialogOptions, + parent = currentDialogWindow()): Promise => { const controller = new AbortController() ordinaryDialogs.add(controller) try { - const parent = currentDialogWindow() if (parent === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false } return await updateDialog.show(parent, { ...options, signal: controller.signal }) } @@ -528,11 +535,14 @@ async function main(): Promise { return { start: async () => { const ready = await host.start() - hostCookie = await authenticateWebHost(ready.url) + const cookie = await authenticateWebHost(ready.url) + hostCookie = cookie hostUrl = ready.url + void removeLegacyHostCookies(session.defaultSession.cookies, ready.url) + .catch((error: unknown) => { console.error('desktop: legacy Host cookie cleanup failed', error) }) if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections') injections = ready.injections - welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; ')) + welcomeBackend = connectDesktopWelcome(ready.url, cookie) analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false) if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) } stopAccount?.() @@ -611,9 +621,9 @@ async function main(): Promise { updateState = state mandatoryUI?.sync() for (const window of BrowserWindow.getAllWindows()) { - window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state)) + window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(state)) } - if (state.phase === 'error' && state.failedOperation !== 'check') { + if (state.phase === 'error' && state.failedOperation !== 'check' && state.failedOperation !== 'configuration') { if (state.failedOperation === 'install' && fileDragStopped && !quitting) { fileDragCleanups.add(installFileDrag()) fileDragStopped = false @@ -675,7 +685,13 @@ async function main(): Promise { } const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) - const updateChannel = desktopUpdateChannel(manifest) + const packagedChannel = desktopUpdateChannel(manifest) + const channelSettings = new DesktopUpdateSettings(app.getPath('userData'), packagedChannel) + const initialChannel = await channelSettings.load() + channelState = { channel: initialChannel.kind === 'ready' ? initialChannel.channel : null, + packagedChannel, changing: false, + ...(initialChannel.kind === 'error' ? { error: configurationFailure(initialChannel.error) } : {}) } + const updateChannel = initialChannel.kind === 'ready' ? initialChannel.channel : packagedChannel const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { @@ -736,12 +752,22 @@ async function main(): Promise { updateChannel, ) + if (initialChannel.kind === 'error') updates.suspendConfiguration(initialChannel.error.path) + const publishChannel = (state: DesktopChannelState): DesktopChannelState => { + channelState = state + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.updatesChannelChanged, state) + window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(updates.state)) + } + return state + } + const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) - const downloadUpdate = async (version: string): Promise => { + const downloadUpdate = async (version: string, revision = updates.revision): Promise => { void track('desktop_upgrade_click', {}) updateJournal?.action('download-requested') - const state = await updates.download(version) + const state = await updates.download(version, revision) if (state.phase !== 'ready' || quitting) return state // Only a completed user-driven download opens this prompt; cancelling installation does not reopen it. // A confirmation on a hidden window would go unseen, so it waits for the next show; the mandatory @@ -749,7 +775,7 @@ async function main(): Promise { if (!isMandatory()) await windowShown() // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A quit can begin while the show is awaited. if (quitting) return state - return updates.install(version) + return updates.install(version, revision) } const windowShown = (): Promise => new Promise((resolve) => { const window = currentDialogWindow() @@ -826,6 +852,7 @@ async function main(): Promise { || !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command') return owner } + installFileActions(assertMainApplication, () => hostUrl) ipcMain.on(PLATFORM_IPC.bootstrap, (event) => { try { event.returnValue = platformView.bootstrap(event) } catch { event.returnValue = null } @@ -865,9 +892,39 @@ async function main(): Promise { windowsLanguage = locale.id refreshApplicationMenu() }) + ipcMain.handle(DESKTOP_IPC.updatesChannelGet, (event) => { + assertProductSender(event) + return channelState + }) + let channelRequests = 0 + ipcMain.handle(DESKTOP_IPC.updatesChannelSet, async (event, channel: unknown): Promise => { + assertProductSender(event) + if (!isDesktopUpdateChannel(channel)) throw new Error('desktop update: unsupported channel') + if (channelState === undefined) throw new Error('desktop update: settings are not initialized') + channelRequests++ + publishChannel({ ...channelState, changing: true }) + try { + const kind = await updates.switchChannel(channel, async () => { + try { await channelSettings.save(channel) } catch (error) { + if (channelSaveFailure(error) === 'uncertain') { + updates.suspendConfiguration('desktop update: settings save outcome is unknown') + publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true, error: 'read' }) + } + throw error + } + }) + if (kind === 'applied') void mandatoryPolicy?.check('channel-change', true).catch((error: unknown) => { console.error(error) }) + return { kind, state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) } + } catch (error) { + console.error('desktop update: channel change failed', error) + const state = publishChannel({ ...channelState, changing: --channelRequests > 0, + ...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) }) + return { kind: 'failed', state, reason: channelSaveFailure(error) } + } + }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) - return presentDesktopUpdate(updates.state) + return updatePresentation(updates.state) }) ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => { assertProductSender(event) @@ -893,9 +950,52 @@ async function main(): Promise { await openUpdatePrompt() }) + let configurationPrompt: Promise | undefined + const showConfigurationError = (): Promise => { + configurationPrompt ??= Promise.resolve().then(async () => { + while (channelState?.error !== undefined) { + const result = await ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle, + message: configurationMessage(channelState.error, locale.messages), + ...{ detail: channelSettings.path, + buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose], + defaultId: 0, cancelId: 2, + keepOpenAction: { index: 1, run: () => { shell.showItemInFolder(channelSettings.path) } }, + action: { index: 0, run: async () => { + if (channelRequests > 0 || channelState === undefined) return false + channelRequests++ + publishChannel({ ...channelState, changing: true }) + try { + const loaded = await channelSettings.load() + if (loaded.kind === 'error') { + publishChannel({ ...channelState, changing: true, error: configurationFailure(loaded.error) }) + return false + } + await updates.switchChannel(loaded.channel, async () => {}) + publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true }) + return true + } catch (error) { + console.error('desktop update: settings reload failed', error) + publishChannel({ ...channelState, + ...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) }) + return false + } finally { + publishChannel({ ...channelState, changing: --channelRequests > 0 }) + } + } } }, + }, mandatoryUI?.confirmationWindow ?? currentDialogWindow()) + if (result.response !== 0) return + } + }).finally(() => { configurationPrompt = undefined }) + return configurationPrompt + } + let promptOperation: Promise | undefined let policyAuthenticationQueued = false + /** Present check results only while their captured channel revision remains current. */ const openUpdatePrompt = (manual = false): Promise => { + if (channelState?.error !== undefined) { + return showConfigurationError() + } if (authenticationOperation !== undefined) { policyAuth?.focus(); updateDialog.focus() } @@ -913,6 +1013,7 @@ async function main(): Promise { let progress: Promise | undefined try { let state = updates.state + const revision = updates.revision if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) { controller = new AbortController() ordinaryDialogs.add(controller) @@ -924,6 +1025,7 @@ async function main(): Promise { } state = await updateSchedule.check(true) } + if (revision !== updates.revision) return if (isMandatory()) { mandatoryUI?.focus(); return } if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return } if (state.phase === 'idle') { @@ -935,7 +1037,7 @@ async function main(): Promise { if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) { if (state.version !== undefined) { failedOperation = 'install' - await showUpdateFailure(await updates.install(state.version)) + await showUpdateFailure(await updates.install(state.version, revision)) } return } @@ -950,7 +1052,7 @@ async function main(): Promise { if (!isMandatory() && state.version !== undefined) { controller?.abort() failedOperation = 'download' - await showUpdateFailure(await downloadUpdate(state.version)) + await showUpdateFailure(await downloadUpdate(state.version, revision)) } } finally { controller?.abort() @@ -1058,8 +1160,8 @@ async function main(): Promise { { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } }, ...process.platform === 'darwin' || process.platform === 'win32' ? [{ label: currentDesktopLocale().messages.cliCommandMenu, click: () => { void commandManager.show() } }] : [], + { type: 'separator' }, ...development ? [ - { type: 'separator' as const }, { label: currentDesktopLocale().messages.reloadPageMenu, role: 'reload' as const }, { label: currentDesktopLocale().messages.restartAppHostMenu, click: () => { if (quitting) return @@ -1067,20 +1169,23 @@ async function main(): Promise { quitWithoutConfirmation() } }, ] : [], + { role: 'toggleDevTools', label: currentDesktopLocale().messages.toggleDevToolsMenu }, { type: 'separator' }, ...hideCommands, { role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication } : process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] - const devToolsItems: MenuItemConstructorOptions[] = [ + const devToolsF12Shortcut: MenuItemConstructorOptions = { role: 'toggleDevTools', visible: false, accelerator: 'F12' } + const windowsDevToolsShortcuts: MenuItemConstructorOptions[] = [ { role: 'toggleDevTools', visible: false }, - { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, + devToolsF12Shortcut, ] const refreshApplicationMenu = (): void => { - Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{ - label: darwin ? app.name : currentDesktopLocale().messages.application, - submenu: [...applicationItems(), ...devToolsItems], - }, ...platformMenus()])) + Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' + ? windowsDevToolsShortcuts : [{ + label: darwin ? app.name : currentDesktopLocale().messages.application, + submenu: [...applicationItems(), devToolsF12Shortcut], + }, ...platformMenus()])) tray?.relabel() } refreshApplicationMenu() @@ -1404,7 +1509,7 @@ async function main(): Promise { let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', - channel: updateChannel, + get channel() { return updates.selectedChannel }, bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false @@ -1422,7 +1527,10 @@ async function main(): Promise { preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale, allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow, policy: () => policy.state, update: () => updates.state, - refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) }, + refresh: async () => { + if (channelState?.error !== undefined) await showConfigurationError() + if (channelState?.error === undefined) await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) + }, download: downloadUpdate, install: version => updates.install(version), }) void mandatoryPolicy.check('launch').then((state) => { diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 92168a7ea3..6c65da3a68 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -8,7 +8,7 @@ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' readonly bundledDshVersion: string readonly arch: 'x64' | 'arm64' - readonly channel: 'nightly' | 'latest' + readonly channel: 'nightly' | 'stable' } /** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ @@ -129,6 +129,7 @@ function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): D export class DesktopMandatoryUpdatePolicy { private current: DesktopPolicyState = { blocking: false, checking: false } private pending: Promise | undefined + private pendingChannel: DesktopPolicyIdentity['channel'] | undefined private controller: AbortController | undefined private timer: ReturnType | undefined private disposed = false @@ -137,7 +138,7 @@ export class DesktopMandatoryUpdatePolicy { /** * @param config - Resolved deployment settings. - * @param identity - Installed software identity, fixed for this application build. + * @param identity - Installed software identity; a channel getter may follow an explicit user selection. * @param publish - Receives policy changes without controlling downloads or existing tasks. * @param request - Anonymous Fetch or the dedicated test-authentication Session transport. * @param client - UI build version, language, and UTC offset, sampled for every check. @@ -156,10 +157,10 @@ export class DesktopMandatoryUpdatePolicy { } /** Platform headers for one check; the calling UI's language and UTC offset are read now. */ - private requestHeaders(): Record { + private requestHeaders(channel: DesktopPolicyIdentity['channel']): Record { return { ...platformClientHeaders(this.identity.platform, this.client()), - 'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel, + 'x-client-arch': this.identity.arch, 'x-client-update-channel': channel, 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, } } @@ -175,9 +176,12 @@ export class DesktopMandatoryUpdatePolicy { */ check(scenario: string, manual = false): Promise { if (this.disposed) return Promise.reject(new Error('desktop policy: disposed')) - if (this.pending !== undefined) return this.pending + if (this.pending !== undefined) return this.pendingChannel === this.identity.channel + ? this.pending : this.pending.then(() => this.check(scenario, true)) if (!manual && Date.now() < this.nextCheck) return Promise.resolve(this.current) clearTimeout(this.timer) + const channel = this.identity.channel + this.pendingChannel = channel this.pending = Promise.resolve().then(async () => { if (this.disposed) return this.current const controller = new AbortController() @@ -187,7 +191,7 @@ export class DesktopMandatoryUpdatePolicy { try { const url = new URL('/api/v0/check_client_update', this.config.origin) url.searchParams.set('scenario', scenario) - const response = await this.request(url, { headers: this.requestHeaders(), signal: controller.signal, + const response = await this.request(url, { headers: this.requestHeaders(channel), signal: controller.signal, credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' }) const body: unknown = await response.json() if (this.config.authentication === 'feishu-test' && response.status === 401 diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c9937a301e..6b4e989614 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,7 @@ +import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types' /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ +import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types' import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' @@ -14,6 +16,11 @@ import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, + fileActions: { + save: request => ipcRenderer.invoke(DESKTOP_IPC.fileSave, request) as Promise, + readClipboardImage: url => ipcRenderer.invoke(DESKTOP_IPC.clipboardImageRead, url) as + Promise<{ bytes: Uint8Array; mediaType: string }>, + }, browser: createDesktopBrowserBridge(), fileDrag: { prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as @@ -59,6 +66,15 @@ function createProductApi(): DshDesktopProductApi { }, }, updates: { + channels: { + get: () => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelGet) as Promise, + set: channel => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelSet, channel) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopChannelState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesChannelChanged, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesChannelChanged, handle) } + }, + }, status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, subscribe(listener) { diff --git a/apps/desktop/src/settings-file.ts b/apps/desktop/src/settings-file.ts new file mode 100644 index 0000000000..023e2e29a6 --- /dev/null +++ b/apps/desktop/src/settings-file.ts @@ -0,0 +1,166 @@ +/** Shared shell settings storage; reads have no side effects and fields fail independently. */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' +import { z } from 'zod' + +const preferencesSchema = z.object({ + allowTestAuthPopupWindow: z.boolean(), + channel: z.enum(['nightly', 'stable']), +}) +const documentSchema = z.looseObject({ updates: z.looseObject({}).optional() }) + +/** Known field values before consumers supply defaults for absent fields. */ +export type DesktopUpdatePreferences = z.infer +/** Unknown root and updates fields survive a read-modify-write operation. */ +export type DesktopSettingsDocument = z.infer +/** Handling-relevant failure details; validation failures never include document contents. */ +export type DesktopSettingsProblem = + | { kind: 'read' } + | { kind: 'invalid'; reason: 'json' | 'structure' } + | { kind: 'invalid'; reason: 'field'; field: keyof DesktopUpdatePreferences } + | { kind: 'write'; operation: 'initialize' | 'save'; effect: 'unchanged' | 'unknown' | 'written' } + +/** A storage or validation failure; business consumers decide fallback or suspension. */ +export class DesktopSettingsFileError extends Error { + /** + * @param problem - Stable failure classification and mutation outcome, when applicable. + * @param path - Settings file location, never file contents. + * @param options - Original filesystem error, when available. + */ + constructor(readonly problem: DesktopSettingsProblem, readonly path: string, options?: ErrorOptions) { + super(`desktop settings: ${problem.kind}: ${path}`, options) + } +} + +/** Absence permits a caller default; an invalid explicit field does not. */ +export type DesktopSettingsField = + | { kind: 'absent' } + | { kind: 'valid'; value: T } + | { kind: 'invalid'; error: DesktopSettingsFileError } + +/** File state and independent field results; a missing file is never implicitly created. */ +export type DesktopSettingsRead = + | { kind: 'missing' } + | { kind: 'error'; error: DesktopSettingsFileError } + | { + kind: 'ready' + document: DesktopSettingsDocument + fields: { [K in keyof DesktopUpdatePreferences]: DesktopSettingsField } + } + +function field(schema: z.ZodType, value: unknown, name: keyof DesktopUpdatePreferences, path: string): DesktopSettingsField { + if (value === undefined) return { kind: 'absent' } + const result = schema.safeParse(value) + return result.success ? { kind: 'valid', value: result.data } + : { kind: 'invalid', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: name }, path) } +} + +/** + * Validate a channel at the IPC boundary using the same schema as file loading. + * @param value - Untrusted input. + * @returns Whether the input is a supported channel. + */ +export function isDesktopUpdateChannel(value: unknown): value is DesktopUpdatePreferences['channel'] { + return preferencesSchema.shape.channel.safeParse(value).success +} + +/** + * Resolve the document shared by shell preferences and update channels. + * @param userData - Electron userData, independent of Host and DSH_HOME. + * @returns Settings file path without filesystem changes. + */ +function desktopSettingsPath(userData: string): string { + return join(userData, 'desktop', 'settings.json') +} + +/** File operations do not watch, cache, choose defaults or apply runtime preferences. */ +export class DesktopSettingsFile { + readonly path: string + + /** @param userData - Electron userData, independent of Host and DSH_HOME. */ + constructor(userData: string) { this.path = desktopSettingsPath(userData) } + + /** @returns Fresh file and field states; performs no creation or repair. */ + async read(): Promise { + let raw: string + try { raw = await readFile(this.path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' } + return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'read' }, this.path, { cause: error }) } + } + let value: unknown + try { value = JSON.parse(raw) } catch (_error) { + // SyntaxError can quote user content; diagnostics expose only the file and failure category. + return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'json' }, this.path) } + } + return this.inspect(value) + } + + private inspect(value: unknown): DesktopSettingsRead { + const result = documentSchema.safeParse(value) + if (!result.success) return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'structure' }, this.path) } + const document = result.data + return { kind: 'ready', document, fields: { + allowTestAuthPopupWindow: field(preferencesSchema.shape.allowTestAuthPopupWindow, + document.updates?.allowTestAuthPopupWindow, 'allowTestAuthPopupWindow', this.path), + channel: field(preferencesSchema.shape.channel, document.updates?.channel, 'channel', this.path), + } } + } + + /** + * Explicit startup initialization; existing files, including invalid ones, are never replaced. + * @returns Current file state after exclusive creation if missing, or a structured failure. + */ + async initialize(): Promise { + const current = await this.read() + if (current.kind !== 'missing') return current + try { return await this.mutate({ kind: 'initialize' }) } catch (error) { + if (!(error instanceof DesktopSettingsFileError)) throw error + return { kind: 'error', error } + } + } + + /** + * Patch known fields under the shared writer lock, preserving unknown and unrelated invalid fields. + * Malformed JSON/structure is rejected; a missing document starts empty. External editors must + * not write concurrently. No runtime settings change here. Write errors distinguish unchanged, + * uncertain write completion, and a committed write whose lock cleanup failed; no rollback is attempted. + * @param updates - Typed fields to replace; omitted keys remain unchanged. + * @returns After atomic replacement and lock release; otherwise throws DesktopSettingsFileError. + */ + async saveUpdates(updates: Partial): Promise { + const result = await this.mutate({ kind: 'save', updates }) + if (result.kind === 'error') throw result.error + } + + private async mutate(request: { kind: 'initialize' } | { kind: 'save'; updates: Partial }): Promise { + let effect: 'unchanged' | 'unknown' | 'written' = 'unchanged' + try { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }) + return await withFileLock(this.path, async () => { + const current = await this.read() + if (current.kind === 'error') throw current.error + if (request.kind === 'initialize' && current.kind !== 'missing') return current + const document = current.kind === 'ready' ? current.document : {} + const updates = request.kind === 'initialize' ? { allowTestAuthPopupWindow: false } : request.updates + const next = { ...document, updates: { ...document.updates, ...updates } } + const raw = `${JSON.stringify(next, null, 2)}\n` + effect = 'unknown' + if (request.kind === 'initialize') { + try { await writeFile(this.path, raw, { flag: 'wx', mode: 0o600 }) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + effect = 'unchanged' + return this.read() + } + } else { + await writeFileAtomic(this.path, raw, { mode: 0o600 }) + } + effect = 'written' + return this.inspect(next) + }) + } catch (error) { + if (error instanceof DesktopSettingsFileError) throw error + throw new DesktopSettingsFileError({ kind: 'write', operation: request.kind, effect }, this.path, { cause: error }) + } + } +} diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts index a4bb67b3bd..55521bbc41 100644 --- a/apps/desktop/src/settings.ts +++ b/apps/desktop/src/settings.ts @@ -1,57 +1,26 @@ -/** - * Local Electron shell settings, loaded once per process independently of the Host. - * Ownership: .agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md. - */ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +/** Startup policy for optional Electron shell preferences, independent of the Host. */ +import { DesktopSettingsFile, type DesktopUpdatePreferences } from './settings-file.ts' -/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */ +/** Resolved popup preference; channel failures do not disable a valid popup preference. */ export interface DesktopSettings { - readonly updates: { - readonly allowTestAuthPopupWindow: boolean - } -} - -function record(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + readonly updates: Pick } /** - * Read shell settings, creating the default document exclusively when missing. - * @param userData - Electron's userData directory, independent of DSH_HOME. - * @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart. + * Initialize the shared document explicitly at startup, then resolve the popup preference. + * @param userData - Electron userData, independent of DSH_HOME. + * @returns Validated popup preference, or false with a diagnostic on failure; changes require restart. */ -export function readDesktopSettings(userData: string): DesktopSettings { - const directory = join(userData, 'desktop') - const path = join(directory, 'settings.json') - const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } } - try { - let raw: string - try { raw = readFileSync(path, 'utf8') } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - mkdirSync(directory, { recursive: true, mode: 0o700 }) - try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) { - // An existing document belongs to the user, including one created during initialization. - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - } - raw = readFileSync(path, 'utf8') +export async function readDesktopSettings(userData: string): Promise { + const file = new DesktopSettingsFile(userData) + const result = await file.initialize() + const preference = result.kind === 'ready' ? result.fields.allowTestAuthPopupWindow : undefined + if (preference?.kind === 'valid') return { updates: { allowTestAuthPopupWindow: preference.value } } + const error = result.kind === 'error' ? result.error : preference?.kind === 'invalid' ? preference.error : undefined + if (error) { + try { console.warn(`desktop settings: ${file.path}: using defaults because settings could not be loaded`, error) } catch (_logError) { + // Optional popup settings remain disabled even when diagnostic output is unavailable. } - const value: unknown = JSON.parse(raw) - if (!record(value) || (value.updates !== undefined && !record(value.updates))) { - throw new Error('expected a JSON object with an optional updates object') - } - const allow = value.updates?.allowTestAuthPopupWindow - if (allow !== undefined && typeof allow !== 'boolean') { - throw new Error('updates.allowTestAuthPopupWindow must be true or false') - } - return { updates: { allowTestAuthPopupWindow: allow ?? false } } - } catch (error) { - try { - console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error) - } catch (_logError) { - // Diagnostic output is best effort; its failure must not make optional settings fatal. - // TODO: Persist settings diagnostics when console output is unavailable, without blocking startup. - } - return defaults } + return { updates: { allowTestAuthPopupWindow: false } } } diff --git a/apps/desktop/src/update-channel.ts b/apps/desktop/src/update-channel.ts index ac767ea642..85f5e10da1 100644 --- a/apps/desktop/src/update-channel.ts +++ b/apps/desktop/src/update-channel.ts @@ -1,7 +1,8 @@ /** Resolve the update channel sealed into the installed application manifest. */ +import type { DesktopUpdatePreferences } from './settings-file.ts' /** Installer-owned channel, independent of release version and upload environment. */ -export type DesktopUpdateChannel = 'nightly' | 'latest' +export type DesktopUpdateChannel = DesktopUpdatePreferences['channel'] /** * Read the packaged channel; older manifests retain their existing nightly behavior. @@ -12,6 +13,6 @@ export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel { if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest') if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly' const channel = manifest.dshDesktopUpdateChannel - if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel') + if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop update: invalid packaged channel') return channel } diff --git a/apps/desktop/src/update-cleanup-log.ts b/apps/desktop/src/update-cleanup-log.ts new file mode 100644 index 0000000000..6171849873 --- /dev/null +++ b/apps/desktop/src/update-cleanup-log.ts @@ -0,0 +1,29 @@ +/** Durable, best-effort diagnostics for obsolete update files that could not be removed. */ +import { appendFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Filesystem operation on a retired target; not evidence that a future download will fail. */ +export interface UpdateCleanupFailure { + readonly operation: 'list' | 'remove' + readonly path: string + readonly error: unknown +} + +/** + * Flush one cleanup warning to disk; diagnostic failure falls back to stderr without reviving a target. + * @param directory - Shell-owned log directory, outside the update cache. + * @param failure - Failed cleanup operation; no update URL or settings contents. + */ +export function recordUpdateCleanupFailure(directory: string, failure: UpdateCleanupFailure): void { + const error = failure.error + const entry = { time: new Date().toISOString(), event: 'update-cache-cleanup-failed', + operation: failure.operation, path: failure.path, + code: error instanceof Error && 'code' in error ? String(error.code) : null, + reason: error instanceof Error ? error.message : String(error) } + try { + mkdirSync(directory, { recursive: true }) + appendFileSync(join(directory, 'update-cleanup.jsonl'), `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) + } catch (logError) { + console.error('desktop update: could not persist cleanup warning', entry, logError) + } +} diff --git a/apps/desktop/src/update-configuration.ts b/apps/desktop/src/update-configuration.ts new file mode 100644 index 0000000000..7e732a4a6a --- /dev/null +++ b/apps/desktop/src/update-configuration.ts @@ -0,0 +1,45 @@ +/** Safe user-facing classifications for shell update settings failures. */ +import type { DesktopChannelChangeResult, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types' +import { DesktopSettingsFileError } from './settings-file.ts' +import type { DesktopMessages } from './locale.ts' + +/** + * Classify an unreadable startup selection without exposing file contents. + * @param error - Validated storage failure. + * @returns The persistent reason updates are suspended. + */ +export function configurationFailure(error: DesktopSettingsFileError): NonNullable { + const problem = error.problem + if (problem.kind !== 'invalid') return 'read' + return problem.reason === 'field' && problem.field === 'channel' ? 'channel' : 'format' +} + +/** + * Reduce an operation failure to a short, trustworthy toast reason. + * @param error - Failed save or channel transition. + * @returns Safe detail for the initiating settings view. + */ +export function channelSaveFailure(error: unknown): Extract['reason'] { + if (!(error instanceof DesktopSettingsFileError)) return 'apply' + if (error.problem.kind === 'write' && error.problem.effect === 'unknown') return 'uncertain' + if (error.problem.kind === 'invalid') return 'format' + const cause = error.cause + if (cause instanceof Error && 'code' in cause && (cause.code === 'EACCES' || cause.code === 'EPERM')) return 'permission' + return 'save' +} + +/** + * Explain an actual suspension and its recovery action. + * @param reason - Persistent updater configuration failure. + * @param messages - Current desktop locale. + * @returns Localized dialog message. + */ +export function configurationMessage(reason: NonNullable, messages: DesktopMessages): string { + const key = { + read: 'updateConfigurationRead', + format: 'updateConfigurationFormat', + channel: 'updateConfigurationChannel', + cleanup: 'updateConfigurationCleanup', + } as const + return messages[key[reason]] +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 3b83cea08f..29cf2d88f3 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -3,33 +3,41 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' -import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater' +import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo, type UpdateCheckResult } from 'electron-updater' import { gt, valid } from 'semver' import type { DesktopUpdateState } from './ipc.ts' -import { DesktopUpdateHttpExecutor } from './update-http-executor.ts' -import { DesktopUpdatePreparationError } from './update-error.ts' +import { DesktopUpdateHttpExecutor } from './updates/electron-updater/http-executor.ts' +import { DesktopUpdatePreparationError, downloadFailureDetails } from './update-error.ts' +import type { DesktopUpdateChannel } from './update-channel.ts' +import { selectUpdateChannel } from './updates/electron-updater/channel.ts' +import { discardDesktopUpdate } from './updates/electron-updater/discard.ts' const { autoUpdater } = electronUpdater /** Owns one updater target until its download and installation settle. */ export class DesktopUpdateCoordinator { private current: DesktopUpdateState = { phase: 'idle' } - private candidate: string | undefined - private downloaded = false + private release: { kind: 'none' } | { kind: 'checked' | 'downloaded' | 'handed-off'; version: string } = { kind: 'none' } private disposed = false + private generation = 0 + private switching = 0 + private downloadGeneration = 0 + private switchTail: Promise = Promise.resolve() + private cancellation: UpdateCheckResult['cancellationToken'] + private configurationError: 'startup' | 'cleanup' | undefined private checkOperation: Promise | undefined private downloadOperation: Promise | undefined private installOperation: Promise | undefined private readonly onProgress = (progress: ProgressInfo): void => { - if (this.downloadOperation === undefined || this.downloaded) return + if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || this.downloaded) return const percent = Math.min(100, Math.max(0, progress.percent)) this.setState({ phase: percent >= 100 ? 'verifying' : 'downloading', ...this.target(), percent }) } private readonly onDownloaded = (info: UpdateInfo): void => { - if (this.downloadOperation === undefined || info.version !== this.candidate) return - this.downloaded = true + if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || info.version !== this.candidate) return + this.release = { kind: 'downloaded', version: info.version } } private readonly onError = (error: Error): void => { @@ -46,6 +54,7 @@ export class DesktopUpdateCoordinator { * @param enabled - Whether this process has a packaged update source. * @param currentVersion - Actual installed application version. * @param channel - Installed package channel; omitted test/local callers retain nightly. + * @param discard - Clears settled library targets and platform resources before another channel is used. * @param downloadResult - Once per completed download attempt, including platform preparation failures. */ constructor( @@ -55,7 +64,8 @@ export class DesktopUpdateCoordinator { private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), private readonly downloadResult?: (success: boolean, reason?: string) => void, - private readonly channel: 'nightly' | 'latest' = 'nightly', + private channel: DesktopUpdateChannel = 'nightly', + private readonly discard: (updater: AppUpdater) => Promise = discardDesktopUpdate, ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -68,10 +78,7 @@ export class DesktopUpdateCoordinator { } this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false - this.updater.channel = channel - this.updater.allowPrerelease = channel === 'nightly' - // Selecting a channel can enable downgrade in electron-updater. - this.updater.allowDowngrade = false + selectUpdateChannel(this.updater, channel) this.updater.on('download-progress', this.onProgress) this.updater.on('update-downloaded', this.onDownloaded) this.updater.on('error', this.onError) @@ -80,6 +87,66 @@ export class DesktopUpdateCoordinator { /** Latest observable state; complete download identity remains main-process-owned. */ get state(): DesktopUpdateState { return this.current } + /** Reason ordinary updates are suspended, independently of the last operation result. */ + get configurationFailure(): 'startup' | 'cleanup' | undefined { return this.configurationError } + + /** Selected runtime channel, including while a new feed is being checked. */ + get selectedChannel(): DesktopUpdateChannel { return this.channel } + + /** Revision captured by confirmations so channel changes invalidate old user actions. */ + get revision(): number { return this.generation } + + /** + * Suspend ordinary updates when settings or previous-target cleanup are invalid. + * @param detail - File path and safe diagnostic, without document contents. + * @param reason - Startup validation or retirement of a previous target. + */ + suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup'): void { + this.configurationError = reason + this.setState({ phase: 'error', failedOperation: 'configuration', message: detail }) + } + + /** + * Persist a UI choice, retire the old target, and check the selected feed. + * Accepted choices serialize; installation cannot start while any choice is pending. + * Persistence failure leaves the previous channel and target intact. Cleanup failure + * suspends updates until another explicit switch successfully retires the target. + * @param channel - Validated UI choice. + * @param persist - Atomic settings save; rejects before a choice is applied on failure. + * @returns Whether the selection changed; healthy repeated choices preserve the current operation. + */ + switchChannel(channel: DesktopUpdateChannel, persist: () => Promise): Promise<'applied' | 'unchanged'> { + this.assertLive() + if (this.installOperation !== undefined || this.current.phase === 'installing' || this.release.kind === 'handed-off') { + return Promise.reject(new Error('desktop update: installation is in progress')) + } + this.switching++ + const operation = this.switchTail.then(async () => { + this.assertLive() + if (channel === this.channel && this.configurationError === undefined) return 'unchanged' as const + await persist() + this.generation++ + this.cancellation?.cancel() + await Promise.allSettled([this.checkOperation, this.downloadOperation]) + this.assertLive() + this.release = { kind: 'none' } + this.cancellation = undefined + try { await this.discard(this.updater) } catch (error) { + this.suspendConfiguration('desktop update: could not retire the previous update', 'cleanup') + throw error + } + this.channel = channel + selectUpdateChannel(this.updater, channel) + this.configurationError = undefined + this.setState({ phase: 'checking' }) + const result = await this.doCheck() + if (result.phase === 'error') this.setState(result) + return 'applied' as const + }).finally(() => { this.switching-- }) + this.switchTail = operation.then(() => {}).catch(() => { /* The caller reports this selection's failure; later choices may retry. */ }) + return operation + } + /** * Check metadata without downloading, joining any current check. * @param manual - Whether a failed check must remain visible in the status indicator. @@ -87,7 +154,8 @@ export class DesktopUpdateCoordinator { */ async check(manual = false): Promise { this.assertLive() - if (this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current + if (this.configurationError !== undefined || this.switching > 0 + || this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current if (!manual && this.current.phase === 'error' && this.current.failedOperation === 'download') return this.current this.checkOperation ??= Promise.resolve().then(() => this.doCheck()) .finally(() => { this.checkOperation = undefined }) @@ -98,24 +166,31 @@ export class DesktopUpdateCoordinator { /** * @param version - Version shown in the user's download confirmation. + * @param revision - Revision captured by the download confirmation; omitted uses the current target. * @returns Download readiness or failure, without authorizing installation. */ - async download(version: string): Promise { + async download(version: string, revision = this.generation): Promise { this.assertLive() + if (revision !== this.generation || this.configurationError !== undefined || this.switching > 0) return this.current if (this.downloaded || this.installOperation !== undefined) return this.current + const generation = this.generation + this.downloadGeneration = generation this.downloadOperation ??= Promise.resolve().then(async () => { await this.checkOperation + if (generation !== this.generation) return this.current this.assertLive() if (this.candidate === undefined) throw new Error('desktop update: no checked update is available') if (version !== this.candidate) throw new Error('desktop update: download confirmation is stale') this.setState({ phase: 'downloading', version, percent: 0 }) try { - await this.updater.downloadUpdate() + await this.updater.downloadUpdate(this.cancellation) + if (generation !== this.generation || this.disposed) return this.current if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness') this.downloadResult?.(true) return this.setState({ phase: 'ready', version }) } catch (error) { - this.downloaded = false + if (generation !== this.generation || this.disposed) return this.current + this.release = { kind: 'checked', version } this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed') return this.setState(this.failure(error, 'download')) } @@ -126,16 +201,20 @@ export class DesktopUpdateCoordinator { /** * Install a prepared target after a separate user confirmation. * @param version - Exact version displayed in the confirmation, never a renderer-selected URL. + * @param revision - Revision captured when the confirmation began; omitted uses the current target. * @returns Installation handoff or a recoverable preparation error. */ - async install(version: string): Promise { + async install(version: string, revision = this.generation): Promise { this.assertLive() + if (revision !== this.generation) return this.current + if (this.configurationError !== undefined || this.switching > 0) throw new Error('desktop update: channel selection is not ready') if (!this.downloaded || this.downloadOperation !== undefined || version !== this.candidate) throw new Error('desktop update: confirmed target is not ready') this.installOperation ??= Promise.resolve().then(async () => { this.setState({ phase: 'installing', version }) try { if (!await this.beforeRestart()) return this.setState({ phase: 'ready', version }) this.assertLive() + this.release = { kind: 'handed-off', version } this.updater.quitAndInstall(true, true) return this.current } catch (error) { @@ -152,10 +231,14 @@ export class DesktopUpdateCoordinator { this.updater.off('download-progress', this.onProgress) this.updater.off('update-downloaded', this.onDownloaded) // Pending updater promises can still emit EventEmitter errors during shutdown. - void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation]) + void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation, this.switchTail]) .then(() => { this.updater.off('error', this.onError) }) } + private get candidate(): string | undefined { return this.release.kind === 'none' ? undefined : this.release.version } + + private get downloaded(): boolean { return this.release.kind === 'downloaded' || this.release.kind === 'handed-off' } + private assertLive(): void { if (this.disposed) throw new Error('desktop update: coordinator is disposed') } @@ -171,6 +254,7 @@ export class DesktopUpdateCoordinator { private failure(error: unknown, failedOperation: 'check' | 'download' | 'install'): DesktopUpdateState { return { phase: 'error', ...this.target(), failedOperation, message: error instanceof Error ? error.message : String(error), + ...(failedOperation === 'download' ? downloadFailureDetails(error) : {}), ...(error instanceof DesktopUpdatePreparationError ? { preparationFailure: error.kind, ...(error.technicalDetails === undefined ? {} : { technicalDetails: error.technicalDetails }), @@ -182,17 +266,22 @@ export class DesktopUpdateCoordinator { } private async doCheck(): Promise { + const generation = this.generation try { this.assertLive() if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source') const result = await this.updater.checkForUpdates() + if (generation !== this.generation || this.disposed) return this.current if (result === null) throw new Error('desktop update: no check result was returned') + this.cancellation = result.cancellationToken const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') - if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') - this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined + if (this.channel === 'stable' && version.includes('-')) throw new Error('desktop update: stable feed contains a prerelease') + this.release = result.isUpdateAvailable && gt(version, this.currentVersion()) + ? { kind: 'checked', version } : { kind: 'none' } return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { + if (generation !== this.generation || this.disposed) return this.current return this.failure(error, 'check') } } diff --git a/apps/desktop/src/update-dialog.ts b/apps/desktop/src/update-dialog.ts index e7a7916028..65efdd80cd 100644 --- a/apps/desktop/src/update-dialog.ts +++ b/apps/desktop/src/update-dialog.ts @@ -8,6 +8,8 @@ export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: /** Text and choices supplied by the main process, never by product documents. */ export interface UpdateDialogView { + /** Button running a main-owned action; responses are disabled until it settles. */ + readonly busyButton?: number /** Identifies the displayed choices; a response from an older prompt is rejected. */ readonly revision: number readonly locale: string @@ -24,6 +26,10 @@ export interface UpdateDialogView { /** Electron message options with separately expandable, main-owned diagnostics. */ export interface UpdateDialogOptions extends MessageBoxOptions { readonly technicalDetails?: string + /** Synchronous auxiliary action that leaves the current prompt and choices open. */ + readonly keepOpenAction?: { index: number; run: () => void } + /** Main-owned action kept in this dialog; false keeps the prompt open for correction. */ + readonly action?: { index: number; run: () => Promise } } /** The document can select only a displayed response index. */ @@ -43,7 +49,11 @@ export class DesktopUpdateDialog { private view: DesktopUpdateOverlay | undefined private parent: BrowserWindow | undefined private closing: ReturnType | undefined - private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined + private active: { + view: UpdateDialogView + finish: (index: number, retain?: boolean) => void + respond: (index: number) => Promise + } | undefined /** Focus the current explanation or confirmation without replacing it or granting permission. */ focus(): void { @@ -71,7 +81,8 @@ export class DesktopUpdateDialog { || (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) { throw new Error('desktop update: invalid dialog response') } - active.finish(index) + if (active.view.busyButton !== undefined) return + return active.respond(index) }) } @@ -95,7 +106,7 @@ export class DesktopUpdateDialog { const surface = existing ?? this.overlays.create(parent, this.preload) this.view = surface this.parent = parent - const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, + let view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message, detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose, technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: locale.messages.updateTechnicalDetails } return new Promise((resolve) => { @@ -110,7 +121,35 @@ export class DesktopUpdateDialog { } resolve({ response, checkboxChecked: false }) } - this.active = { view, finish } + const respond = async (index: number): Promise => { + const publish = (busy: boolean): void => { + const { busyButton: _busy, ...rest } = view + view = { ...rest, revision: ++this.revision, ...(busy ? { busyButton: index } : {}) } + if (this.active !== undefined) this.active.view = view + surface.webContents.send(UPDATE_DIALOG_IPC.changed, view) + } + if (options.keepOpenAction?.index === index) { + options.keepOpenAction.run() + publish(false) + return + } + if (options.action?.index !== index) { finish(index); return } + publish(true) + // Keep a fast local read perceptible without delaying the operation itself. + const held = new Promise((resolveHold) => { setTimeout(resolveHold, 500) }) + try { + const close = await options.action.run() + await held + if (this.active?.view !== view) return + if (close) finish(index) + else publish(false) + } catch (error) { + await held + if (this.active?.view === view) publish(false) + throw error + } + } + this.active = { view, finish, respond } options.signal?.addEventListener('abort', abort, { once: true }) if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return } const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } } diff --git a/apps/desktop/src/update-error.ts b/apps/desktop/src/update-error.ts index adac72738b..a2a9f23c5e 100644 --- a/apps/desktop/src/update-error.ts +++ b/apps/desktop/src/update-error.ts @@ -1,5 +1,5 @@ /** Localized update preparation failures with separately displayed diagnostics. */ -import type { DesktopUpdatePreparationFailureKind } from './ipc.ts' +import type { DesktopUpdatePreparationFailureKind, DesktopUpdateState } from './ipc.ts' /** Only explicitly safe main-process facts may be exposed as technical details. */ export class DesktopUpdatePreparationError extends Error { @@ -10,3 +10,20 @@ export class DesktopUpdatePreparationError extends Error { */ constructor(readonly kind: DesktopUpdatePreparationFailureKind, message: string, readonly technicalDetails?: string) { super(message) } } + +/** + * Preserve current download diagnostics; never infer a cause from an older cleanup failure. + * @param error - Rejected updater download or filesystem error. + * @returns Structured filesystem cause when supplied by the failing operation. + */ +export function downloadFailureDetails(error: unknown): Pick { + if (!(error instanceof Error) || !('code' in error) || typeof error.code !== 'string') return {} + const code = error.code + const kind = code === 'ENOSPC' ? 'space' : ['EACCES', 'EPERM', 'EROFS'].includes(code) ? 'permission' : code === 'EBUSY' ? 'busy' : undefined + const path = 'path' in error && typeof error.path === 'string' ? error.path : undefined + const destination = 'dest' in error && typeof error.dest === 'string' ? error.dest : undefined + return { + ...(kind === undefined ? {} : { downloadFailure: kind }), + technicalDetails: [code, error.message, path, destination].filter(value => value !== undefined).join('\n'), + } +} diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts index 670b6eb4b6..4fa061f33a 100644 --- a/apps/desktop/src/update-presentation.ts +++ b/apps/desktop/src/update-presentation.ts @@ -10,7 +10,9 @@ const NETWORK_FAILURE = /\b(?:ERR_CONNECTION_CLOSED|ERR_CONNECTION_RESET|ERR_INT * @returns Stable presentation kind without raw diagnostics. */ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailureKind { + if (state.failedOperation === 'configuration') return 'configuration' if (state.failedOperation === 'install' && state.preparationFailure !== undefined) return state.preparationFailure + if (state.failedOperation === 'download' && state.downloadFailure !== undefined) return `download-${state.downloadFailure}` const operation = state.failedOperation ?? 'install' return NETWORK_FAILURE.test(state.message ?? '') ? `${operation}-network` : operation } @@ -24,9 +26,13 @@ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailu export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: DesktopMessages): string { const kind = desktopUpdateFailureKind(state) const summaries: Readonly> = { + configuration: messages.updateConfigurationFailed, check: messages.updateCheckFailed, 'check-network': messages.updateCheckNetworkFailed, download: messages.updateDownloadFailed, + 'download-space': messages.updateDownloadSpaceFailed, + 'download-permission': messages.updateDownloadPermissionFailed, + 'download-busy': messages.updateDownloadBusyFailed, 'download-network': messages.updateDownloadNetworkFailed, install: messages.updateInstallFailed, 'install-network': messages.updateInstallNetworkFailed, diff --git a/apps/desktop/src/update-settings.ts b/apps/desktop/src/update-settings.ts new file mode 100644 index 0000000000..bad7f6ee2b --- /dev/null +++ b/apps/desktop/src/update-settings.ts @@ -0,0 +1,52 @@ +/** Channel-specific default and failure policy over the shared shell settings document. */ +import { DesktopSettingsFile, DesktopSettingsFileError } from './settings-file.ts' +import { appendFileSync } from 'node:fs' +import type { DesktopUpdateChannel } from './update-channel.ts' + +/** Invalid explicit channels suspend ordinary updates; only absence uses the package default. */ +export type DesktopUpdateSettingsResult = + | { kind: 'ready'; channel: DesktopUpdateChannel } + | { kind: 'error'; error: DesktopSettingsFileError } + +/** No file watching; loading and explicit UI saves share shell storage and validation. */ +export class DesktopUpdateSettings { + private readonly file: DesktopSettingsFile + readonly path: string + + /** + * @param userData - Electron userData, independent of Host and DSH_HOME. + * @param packagedChannel - Default selected by the installer build input. + */ + constructor(userData: string, private readonly packagedChannel: DesktopUpdateChannel) { + this.file = new DesktopSettingsFile(userData) + this.path = this.file.path + } + + /** @returns Startup channel or an error requiring suspension; never creates a missing file. */ + async load(): Promise { + const result = await this.file.read() + if (result.kind === 'error') return result + if (result.kind === 'missing') return { kind: 'ready', channel: this.packagedChannel } + const channel = result.fields.channel + if (channel.kind === 'invalid') return { kind: 'error', error: channel.error } + return { kind: 'ready', channel: channel.kind === 'valid' ? channel.value : this.packagedChannel } + } + + /** + * Save a selection while preserving other fields; never changes the runtime selection itself. + * @param channel - Validated UI selection. + * @returns After a confirmed write, including logged lock-release failures. + * Uncertain writes reject without retry; diagnostics are appended to settings.json.save.jsonl. + */ + async save(channel: DesktopUpdateChannel): Promise { + try { await this.file.saveUpdates({ channel }) } catch (error) { + if (!(error instanceof DesktopSettingsFileError) || error.problem.kind !== 'write') throw error + const entry = { time: new Date().toISOString(), event: 'settings-save-failed', effect: error.problem.effect, + path: this.path, reason: error.cause instanceof Error ? error.cause.message : String(error.cause) } + try { appendFileSync(`${this.path}.save.jsonl`, `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) } catch (logError) { + console.error('desktop settings: could not persist save warning', entry, logError) + } + if (error.problem.effect !== 'written') throw error + } + } +} diff --git a/apps/desktop/src/updates/electron-updater/channel.ts b/apps/desktop/src/updates/electron-updater/channel.ts new file mode 100644 index 0000000000..06f07627b2 --- /dev/null +++ b/apps/desktop/src/updates/electron-updater/channel.ts @@ -0,0 +1,14 @@ +/** Channel policy applied after electron-updater's channel setter. */ +import type { AppUpdater } from 'electron-updater' + +/** + * Select a feed without permitting downgrades or prereleases on stable. + * @param updater - Updater whose prior operations have settled. + * @param channel - Selected distribution channel. + */ +export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 'stable'): void { + updater.channel = channel + // The library channel setter enables downgrades. + updater.allowDowngrade = false + updater.allowPrerelease = channel === 'nightly' +} diff --git a/apps/desktop/src/updates/electron-updater/discard.ts b/apps/desktop/src/updates/electron-updater/discard.ts new file mode 100644 index 0000000000..f066141c42 --- /dev/null +++ b/apps/desktop/src/updates/electron-updater/discard.ts @@ -0,0 +1,62 @@ +/** Retires electron-updater 6.x targets only after owned checks and downloads settle. */ +import type { Server } from 'node:http' +import { readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { app } from 'electron' +import { recordUpdateCleanupFailure } from '../../update-cleanup-log.ts' +import type { AppUpdater } from 'electron-updater' + +/** + * Revoke the library's pending target and await macOS's local feed server closure. + * This adapter depends on private electron-updater 6.x fields, covered by real-library tests. + * autoInstallOnAppQuit must be false and quitAndInstall must not have been called. + * @param updater - Settled updater owned by the Desktop coordinator. + * @returns After old in-memory targets and the local feed server are retired; residual files only produce durable warnings. + */ +export async function discardDesktopUpdate(updater: AppUpdater): Promise { + // The dependency omits these private fields from its public integration declarations. + const internal = updater as object as { + updateInfoAndProvider: unknown + downloadedUpdateHelper?: { + _file: string | null + _packageFile: string | null + versionInfo: unknown + fileInfo: unknown + _downloadedFileInfo: unknown + cacheDirForPendingUpdate: string + } | null + server?: Server | null + squirrelDownloadedUpdate?: boolean + } + if (internal.squirrelDownloadedUpdate === true) throw new Error('desktop update: native installation already staged') + const server = internal.server + if (server?.listening) { + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + } + internal.server = null + internal.updateInfoAndProvider = null + const helper = internal.downloadedUpdateHelper + if (helper == null) return + // Equivalent to the pinned helper's memory reset; its clear() silently swallows filesystem failures. + helper._file = null + helper._packageFile = null + helper.versionInfo = null + helper.fileInfo = null + helper._downloadedFileInfo = null + const directory = helper.cacheDirForPendingUpdate + let names: string[] + try { names = await readdir(directory) } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'list', path: directory, error }) + } + return + } + for (const name of names) { + const path = join(directory, name) + try { await rm(path, { recursive: true, force: true }) } catch (error) { + recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'remove', path, error }) + } + } +} diff --git a/apps/desktop/src/update-http-executor.ts b/apps/desktop/src/updates/electron-updater/http-executor.ts similarity index 100% rename from apps/desktop/src/update-http-executor.ts rename to apps/desktop/src/updates/electron-updater/http-executor.ts diff --git a/apps/desktop/src/web-document.ts b/apps/desktop/src/web-document.ts index a74ea7c7ef..15f289e14b 100644 --- a/apps/desktop/src/web-document.ts +++ b/apps/desktop/src/web-document.ts @@ -49,6 +49,26 @@ export async function authenticateWebHost(url: string): Promise { return end < 0 ? cookie : cookie.slice(0, end) } +/** Cookie store operations used to remove legacy Host authentication cookies. */ +export interface HostCookieStore { + get(filter: { url: string }): Promise + remove(url: string, name: string): Promise +} + +/** + * Remove every `dsh-auth-*` cookie that earlier releases left in a cookie store for the Host's hostname. + * Cookies ignore ports, so cookies from earlier random-port Hosts would otherwise travel with every + * request to that hostname until they expire. + * @param cookies - Electron session cookie store. + * @param url - Host URL; cookies for its hostname on every port are removed. + */ +export async function removeLegacyHostCookies(cookies: HostCookieStore, url: string): Promise { + const origin = new URL(url).origin + for (const { name } of await cookies.get({ url: origin })) { + if (name.startsWith('dsh-auth-')) await cookies.remove(origin, name) + } +} + /** * Response headers not relayed to the renderer. `set-cookie` would hand the * Host's authentication cookie to the page's cookie jar, which the shell owns diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts index 54ab46d85e..15b0932cb2 100644 --- a/apps/desktop/src/welcome-backend.ts +++ b/apps/desktop/src/welcome-backend.ts @@ -35,20 +35,18 @@ function record(value: unknown): value is Record { } /** - * Authenticate the native HTTP client through the Web application's launch URL. - * @param authenticatedUrl - URL supplied by the running Desktop Host. - * @param send - Electron session fetch, retaining the Web authentication cookie. + * Connect the native HTTP client to the Desktop Host; every request sends `cookie` explicitly and uses no cookie jar. + * @param hostUrl - URL of the running Desktop Host; only its origin is used. + * @param cookie - Cookie header returned by `authenticateWebHost` for this Host. + * @param send - HTTP client for Host requests. * @returns metadata reads and write-only credential operations over standard RPC. */ -export async function connectDesktopWelcome( - authenticatedUrl: string, - send: (input: string, init?: RequestInit) => Promise, - cookies: () => Promise = () => Promise.resolve(''), -): Promise { - const origin = new URL(authenticatedUrl).origin - const authenticated = await send(authenticatedUrl, { credentials: 'include' }) - await authenticated.body?.cancel() - if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed') +export function connectDesktopWelcome( + hostUrl: string, + cookie: string, + send: (input: string, init?: RequestInit) => Promise = fetch, +): DesktopWelcomeBackend { + const origin = new URL(hostUrl).origin const invoke = async ( request: { namespace: string; method: string; args: Record }, signal?: AbortSignal, @@ -56,8 +54,8 @@ export async function connectDesktopWelcome( const rpcId = randomUUID() const method = `${request.namespace}/${request.method}` const response = await send(new URL(`/api/${method}`, origin).href, { - method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }), - headers: { 'content-type': 'application/json' }, + method: 'POST', redirect: 'error', ...(signal === undefined ? {} : { signal }), + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }), }) if (!response.ok) throw new Error('desktop welcome: Web request failed') @@ -68,7 +66,7 @@ export async function connectDesktopWelcome( } return envelope.result.value } - const account = desktopAccountBackend(origin, invoke, cookies) + const account = desktopAccountBackend(origin, invoke, cookie) const settingsAndReference = async () => { const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} }) if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces') diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index 97e9812489..58cfb79e5b 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6f4de4503748f3e2 zh: 648370375f16e463 /desktop-local-verification/manual-walkthrough: - en: c4b4ee8763d8464a - zh: ee2159bc08966592 + en: 1f333416e5a3fef1 + zh: fe0f30418db59cb0 /desktop-local-verification/evidence: en: dcb2473264c825d4 zh: cebb2832efa15974 diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index cda4533699..9c165af2e0 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -65,6 +65,8 @@ With Host, client, and Desktop artifacts built, run `node --import tsx apps/desk
+After building Desktop, `node apps/desktop/scripts/test-update-channels.mjs` checks the pinned Electron updater against loopback feeds and downloaded inert files. It verifies channel filenames, downgrade prohibition, rollout eligibility, and retirement of actual download caches and stale confirmations after channel switching; on macOS it also closes the updater-created local handoff server. Windows pull-request CI runs this check after the blocking build. No installer, Host or remote publication runs. This qualification does not replace signed installed-app testing on Windows and macOS. + ## Evidence The local command builds Desktop and executes Electron 44 with the actual HTTP updater, policy client, sandboxed preload, and mandatory renderer. It records each scenario and preserves its report under `.desktop-build/qualification/local-updater-*`. The installer call, external browser, and clipboard are observation substitutes; downloaded bytes are not an executable installer. diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index 6cca69e8ac..3c69df8a56 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -65,6 +65,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录 +构建 Desktop 后,`node apps/desktop/scripts/test-update-channels.mjs` 使用本地回环清单和下载的无执行能力文件检查固定版本的 Electron updater。它验证渠道文件名、禁止降级、灰度命中,以及切换渠道后实际下载缓存和过期确认的撤销,在 macOS 上还关闭 updater 创建的本地交接服务。Windows pull request CI 在阻塞性构建后运行此检查。不运行安装器、Host 或远程发布。此验证不能替代 Windows 和 macOS 的签名安装应用测试。 + ## 验证证据 本地命令构建 Desktop,并在 Electron 44 中运行真实 HTTP updater、策略客户端、沙箱预加载和强更页面。它记录每个场景,并把报告保存在 `.desktop-build/qualification/local-updater-*`。安装器调用、外部浏览器和剪贴板均替换为观测记录;下载字节不是可执行安装器。 diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index c239336b22..ffdc59d340 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -1,4 +1,5 @@ import { once } from 'node:events' +import type { IncomingMessage } from 'node:http' import { WebSocketServer } from 'ws' import { expect, it, onTestFinished, vi } from 'vitest' import { accountView, desktopAccountBackend } from '../src/account-backend.ts' @@ -28,7 +29,7 @@ it('uses account Remote commands without returning additional wire fields', asyn const backend = desktopAccountBackend('http://127.0.0.1:1234', (request) => { requests.push(request) return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' }) - }, () => Promise.resolve('')) + }, '') const client = { version: '1.2.3', locale: 'en', timezoneOffsetSeconds: 28_800 } expect(await backend.start(client)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) expect(await backend.signOut(client)).toMatchObject({ status: 'signed-out' }) @@ -59,12 +60,13 @@ it('receives live expiry separately from account snapshots', async () => { const snapshot = { status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } const received = Promise.withResolvers() - const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve('')) + const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), 'dsh-auth-fixture=signed') const stop = backend.watch( (value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged, ) onTestFinished(stop) - await connected + const [, upgrade] = await connected as [unknown, IncomingMessage] + expect(upgrade.headers.cookie).toBe('dsh-auth-fixture=signed') // Logical streams share the socket; observe their opening frames before sending data. const streams = new Map() const opened = Promise.withResolvers() diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 9fbe629c89..74fabee903 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { desktopBuildRecordFilename, + desktopWebsiteInstallerUrl, desktopUpdateMetadataFilename, resolveDesktopAutoUpdateConfig, resolveDesktopAutoUpdateEnvironment, @@ -119,3 +120,21 @@ describe('desktop auto-update environment', () => { expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u) }) }) + +it.each([ + ['mac-arm64', 'dsh-latest-macos-arm64.dmg'], + ['mac-x64', 'dsh-latest-macos-x64.dmg'], + ['win-x64', 'dsh-latest-windows-x64.exe'], +] as const)('uses the production origin for website target %s', (target, filename) => { + expect(desktopWebsiteInstallerUrl(target)).toBe(`https://download.deepseek.com/desktop/${filename}`) +}) + +it.each([['darwin', 'arm64', 'mac-arm64', 'stable-mac.yml'], ['darwin', 'x64', 'mac-x64', 'stable-mac.yml'], ['win32', 'x64', 'win-x64', 'stable.yml']] as const) +('routes stable metadata and binaries separately for %s %s', (platform, arch, target, filename) => { + const config = resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DSH_DESKTOP_UPDATE_CHANNEL: 'stable' }, platform, arch) + expect(config.channel).toBe('stable') + expect(config.keyPrefix).toBe(`dsh-desk/feeds/${target}`) + expect(config.binaryKeyPrefix).toBe(`dsh-desk/bin/${target}/stable`) + expect(desktopUpdateMetadataFilename('1.0.0', platform, config.channel)).toBe(filename) + expect(() => resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_UPDATE_CHANNEL: 'latest' }, platform, arch)).toThrow(/channel/u) +}) diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index 1da8dde6e8..54d2aaec13 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -9,7 +9,7 @@ import { retainDesktopRelease, verifyRetainedDesktopRelease } from '../scripts/d function fixture() { const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), status: 'completed', conclusion: 'success', event: 'workflow_dispatch', path: '.github/workflows/macos-package.yml', repository: { full_name: 'owner/repo' } } - const jobs = [{ name: 'package', runner_name: 'mini', conclusion: 'success' }] + const jobs = [{ name: 'package', run_attempt: 2, runner_name: 'mini', conclusion: 'success' }] const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } } const calls: string[] = [] const api = (path: string, paginate?: boolean) => { @@ -39,6 +39,7 @@ it('routes a Windows package through its own configured machine target', () => { it('selects an explicit old attempt without consulting the latest attempt', () => { const f = fixture() f.run.run_attempt = 1 + f.jobs[0]!.run_attempt = 1 selectDesktopPackage('owner/repo', { package_run_id: '42', package_run_attempt: '1' }, f.routes, f.api) expect(f.calls).not.toContain('repos/owner/repo/actions/runs/42') expect(f.calls).toContain('repos/owner/repo/actions/runs/42/attempts/1') @@ -47,8 +48,8 @@ it('selects an explicit old attempt without consulting the latest attempt', () = it.each(['failed', 'cancelled', 'running', 'workflow', 'repository', 'attempt', 'run', 'job', 'ambiguous', 'route', 'label'])( 'rejects %s evidence without falling back to a different attempt', (kind) => { const f = fixture() - if (kind === 'failed') f.run.conclusion = 'failure' - if (kind === 'cancelled') f.run.conclusion = 'cancelled' + if (kind === 'failed') { f.run.conclusion = 'failure'; f.jobs[0]!.conclusion = 'failure' } + if (kind === 'cancelled') { f.run.conclusion = 'cancelled'; f.jobs[0]!.conclusion = 'cancelled' } if (kind === 'running') f.run.status = 'in_progress' if (kind === 'workflow') f.run.path = '.github/workflows/ci.yml' if (kind === 'repository') f.run.repository.full_name = 'other/repo' @@ -135,3 +136,24 @@ it('routes both Mac architectures to one machine without authorizing an unlisted expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow() } }) + +it.each([false, true])('keeps original build after archival retry with reused job listed: %s', (includesReused) => { + const f = fixture() + const selected = selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, (path) => { + if (path.endsWith('/42')) return { run_attempt: 3 } + if (path.endsWith('/attempts/3')) return { ...f.run, run_attempt: 3 } + if (path.endsWith('/attempts/3/jobs?per_page=100')) return [{ jobs: [{ name: 'upload-artifact', run_attempt: 3, runner_name: 'mini', conclusion: 'success' }, ...(includesReused ? f.jobs : [])] }] + if (path.includes('filter=all')) return [{ jobs: [{ ...f.jobs[0], run_attempt: 2 }] }] + if (path.endsWith('/attempts/2')) return { ...f.run, conclusion: 'failure' } + return [{ jobs: f.jobs }] + }) + expect(selected.run.run_attempt).toBe(2) + expect(selected.runnerName).toBe('mini') +}) + +it('accepts a successful package when only archival failed', () => { + const f = fixture() + f.run.conclusion = 'failure' + f.jobs.push({ name: 'upload-artifact', run_attempt: 3, runner_name: 'mini', conclusion: 'failure' }) + expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api).run.run_attempt).toBe(2) +}) diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts index 6f9e8d05e6..80daeab539 100644 --- a/apps/desktop/tests/desktop-publication-failure.spec.ts +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -44,3 +44,15 @@ it('omits malformed provider receipt fields and never copies raw errors into COS expect(failure).not.toHaveProperty('httpStatus') expect(JSON.stringify(failure)).not.toContain('fixture-secret') }) + +it('prints structured archival failure through the real CLI', () => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', + fileURLToPath(new URL('../scripts/archive-desktop-ci.ts', import.meta.url))], { + encoding: 'utf8', timeout: 30_000, env: { ...process.env, RUNNER_NAME: '', DESKTOP_PACKAGE_RUNNER: '' }, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + expect(JSON.parse(result.stderr)).toEqual({ phase: 'artifact archival', code: 'validation', + message: 'desktop archival: wrong original machine' }) +}) diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 39e717a827..04402925a5 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -39,6 +39,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu })) const env = { DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test', + DESKTOP_PUBLISH_RUNNERS: JSON.stringify({ 'fixture-runner': { label: 'dsh-fixture', target: [target] } }), DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner', @@ -52,7 +53,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(prepared.plan.version).toBe('1.0.0') expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) expect(signatureChecks).toBe(1) - const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64)) + const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64), false) await expect(summary).toMatchFileSnapshot(`./expected/publication-${target}.md`) const feed = prepared.plan.artifacts.find(file => file.channelMetadata)! expect(summary).toContain(createHash('sha256').update(feed.contents!).digest('hex')) diff --git a/apps/desktop/tests/desktop-publication-summary.spec.ts b/apps/desktop/tests/desktop-publication-summary.spec.ts index 5267d29034..11ca2d064e 100644 --- a/apps/desktop/tests/desktop-publication-summary.spec.ts +++ b/apps/desktop/tests/desktop-publication-summary.spec.ts @@ -6,25 +6,25 @@ import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' const source: DesktopRetainedSource = { repository: 'owner/repo', runId: '20', attempt: '3', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40), - sourceRef: 'dsh-v1.0.0', channel: 'latest', runnerName: 'windows-publisher', target: 'win-x64', + sourceRef: 'dsh-v1.0.0', channel: 'stable', runnerName: 'windows-publisher', target: 'win-x64', preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'signed', } const plan: DesktopUploadPlan = { environment: 'production', target: 'win-x64', version: '1.0.0', bucket: 'production-1234567890', publicUrl: 'https://download.example.com/dsh-desk/feeds/win-x64/', secretIdEnvName: 'SECRET_ID', secretKeyEnvName: 'SECRET_KEY', artifacts: [ - { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe', + { path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe', contents: 'installer bytes', contentType: 'application/octet-stream', channelMetadata: false }, - { path: '/not-read/latest.yml', filename: 'latest.yml', key: 'dsh-desk/feeds/win-x64/latest.yml', + { path: '/not-read/stable.yml', filename: 'stable.yml', key: 'dsh-desk/feeds/win-x64/stable.yml', contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true }, ], } it('shows production destinations and tag after approval with upload-byte fingerprints', async () => { - const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64)) + const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64), false) await expect(summary).toMatchFileSnapshot('./expected/publication-production.md') - expect(summary).toContain('/latest/deepseek-harness-1.0.0-win-x64.exe') - expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/latest.yml') + expect(summary).toContain('/stable/deepseek-harness-1.0.0-win-x64.exe') + expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/stable.yml') expect(summary).toContain(createHash('sha256').update('version: 1.0.0\n').digest('hex')) expect(summary).not.toContain('测试 release ID |') expect(summary).not.toContain('SECRET_KEY') @@ -32,7 +32,7 @@ it('shows production destinations and tag after approval with upload-byte finger }) it('escapes runner display text without adding Markdown rows or HTML elements', async () => { - const summary = await desktopPublicationSummary({ ...source, runnerName: 'runner|\n