From 6e49ccad18db47686ca8c5b7c9aae59df5bb1cc9 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 17 Sep 2026 18:16:47 +0800 Subject: [PATCH 01/87] feat(skill): extract the workspace-dependencies tool into a package Move `load_workspace_dependencies` and the primary-runtime payload logic out of `apps/desktop-host` into `@deepseek-ai/dsh-tool-workspace-dependencies` so profiles other than Desktop can mount the same tool. Desktop keeps its behavior through the package. Two additions for carriers that are not Electron applications: - `root` is optional: without it the payload is validated and used in place (no copy under the Harness home), which suits read-only container layers. - `runtime.json` accepts `linux`, and the `node` / `pnpm` components are optional; the returned paths and the tool output omit what the payload does not ship. The `sdk` profile mounts the tool and `@deepseek-ai/dsh-skill-office` when `DSH_PRIMARY_RUNTIME` names a payload directory (its sibling `office-skills/` carries the skill resources); both rows stay disabled otherwise. Both packages join the Python runtime closure. --- apps/cli/package.json | 2 + apps/desktop-host/package.json | 2 +- apps/desktop-host/src/office.ts | 2 +- .../src/workspace-dependencies.ts | 54 ------- apps/desktop-host/tsconfig.json | 2 +- .../scripts/prepare-primary-runtime.ts | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 22 +++ docs/config-catalog.zh.md | 22 +++ docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 18 +++ docs/tool-catalog.zh.md | 18 +++ package.json | 3 +- packages/bundle/sdk-app/cordis.patch.yml | 19 +++ packages/bundle/sdk-app/package.json | 4 +- .../README.i18n.yaml | 6 + .../tool-workspace-dependencies/README.md | 122 ++++++++++++++++ .../tool-workspace-dependencies/README.zh.md | 122 ++++++++++++++++ .../tool-workspace-dependencies/package.json | 41 ++++++ .../tool-workspace-dependencies/src/index.ts | 133 +++++++++++++++--- .../tests/tool-workspace-dependencies.spec.ts | 44 ++++-- .../tool-workspace-dependencies/tsconfig.json | 21 +++ pnpm-lock.yaml | 49 ++++++- python/sdk-runtime/package.json | 2 + scripts/gen-tool-catalog.ts | 12 ++ .../cordis.snapshot.yml | 2 +- .../session/workspace-dependencies/cordis.yml | 2 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 + 29 files changed, 642 insertions(+), 96 deletions(-) delete mode 100644 apps/desktop-host/src/workspace-dependencies.ts create mode 100644 packages/skill/tool-workspace-dependencies/README.i18n.yaml create mode 100644 packages/skill/tool-workspace-dependencies/README.md create mode 100644 packages/skill/tool-workspace-dependencies/README.zh.md create mode 100644 packages/skill/tool-workspace-dependencies/package.json rename apps/desktop-host/src/primary-runtime.ts => packages/skill/tool-workspace-dependencies/src/index.ts (52%) rename apps/desktop-host/tests/primary-runtime.spec.ts => packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts (83%) create mode 100644 packages/skill/tool-workspace-dependencies/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 5204f55a29..97d4bf97f7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,6 +70,8 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index c47acf4612..200190c8df 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -18,7 +18,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-skill-office": "workspace:^" } } diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 9645435e8c..8420aa0feb 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import * as officeSkills from '@deepseek-ai/dsh-skill-office' -import * as workspaceDependencies from './workspace-dependencies.ts' +import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' /** Loader identity for the application-owned Office composition. */ export const name = 'desktop-office' diff --git a/apps/desktop-host/src/workspace-dependencies.ts b/apps/desktop-host/src/workspace-dependencies.ts deleted file mode 100644 index 7444495263..0000000000 --- a/apps/desktop-host/src/workspace-dependencies.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Desktop tool exposing bundled interpreters without modifying command resolution. */ - -import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { installPrimaryRuntime, type WorkspaceDependencies } from './primary-runtime.ts' - -export const name = 'desktop-workspace-dependencies' -export const inject = ['tools'] - -/** Application-selected payload and installation directories. */ -export interface Config { - readonly source: string - readonly root: string -} - -/** - * Register the read-only path query, preparing bundled files on its first invocation. - * @param ctx - Desktop tool registry owner. - * @param config - Application payload and fixed installation paths. - */ -export function apply(ctx: Context, config: Config): void { - let installation: Promise | undefined - ctx.effect(() => async () => { - // Tool execution reports installation failures; disposal only waits for filesystem work to settle. - await installation?.catch(() => undefined) - }) - ctx.tools.register(defineTool({ - name: 'load_workspace_dependencies', - description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', - parameters: {}, - output: { - schema: { - type: 'object', additionalProperties: false, - properties: { - python: { type: 'string', required: true }, - node: { type: 'string', required: true }, - pnpm: { type: 'string', required: true }, - pythonPackages: { type: 'string', required: true }, - nodePackages: { type: 'string', required: true }, - pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' }, - }, - }, - render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }], - }, - execute: () => { - installation ??= installPrimaryRuntime(config.source, config.root).catch((error: unknown) => { - installation = undefined - throw error - }) - return installation - }, - presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }), - })) -} diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 89780f7b94..81277cb3ff 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -13,7 +13,7 @@ { "path": "../../packages/jobs/jobs" }, { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, - { "path": "../../packages/core/tools" }, + { "path": "../../packages/skill/tool-workspace-dependencies" }, { "path": "../cli" }, diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index 9163db1234..aa3ac650cb 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import extractZip from 'extract-zip' import { x as extractTar } from 'tar' -import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' import lock from './primary-runtime-lock.json' with { type: 'json' } @@ -159,6 +159,8 @@ export function smokePrimaryRuntime(root: string): void { if (manifest.platform !== process.platform || manifest.arch !== process.arch) return if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) + // The Desktop payload always ships Node.js and pnpm; only other carriers may omit them. + if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.') const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7da9acd525..27d34f7af2 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 631ce6a34b120f44534a7935ff976662287fa879 -config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4 +config-catalog.md: 64ef9c99d2cc98690097d848ab7cfb899c6bb474 +config-catalog.zh.md: 76faa711e53011ba34d599f59f616280a4660ad0 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 631ce6a34b..64ef9c99d2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3462,6 +3462,28 @@ export interface Config { Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +Requires: `tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8d72786a79..76faa711e5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3464,6 +3464,28 @@ export interface Config { 来源:[`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +依赖:`tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 22a62072ae..95707a37cf 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: cd7805d3104120a04e21363cb75c6dcb8456f5e0 -tool-catalog.zh.md: 52752836f49c8fb745a69f10c4f8cd1ef79a871d +tool-catalog.md: bdc3a5af4b1bf723879c423fe201576ca3772e64 +tool-catalog.zh.md: bc445ea8d6b0a5778e4bd191693ff01bd6781d73 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index cd7805d310..bdc3a5af4b 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -43,6 +43,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -2438,6 +2439,23 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim Source: [`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 52752836f4..bc445ea8d6 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -47,6 +47,7 @@ | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -2446,6 +2447,23 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 来源:[`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +获取随包附带的 Python、Node.js、pnpm 和库目录的绝对路径,以及随包 Python 发行版的版本。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。用返回的 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 + +```json +{ + "type": "object", + "properties": {} +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/package.json b/package.json index bb4d9c3286..52cbabab43 100644 --- a/package.json +++ b/package.json @@ -240,6 +240,7 @@ "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8", - "@deepseek-ai/dsh-skill-office": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" } } diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 91c67debc1..e80f962953 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -23,3 +23,22 @@ - id: hmr disabled: true + +# Bundled script runtime and Office skills, the composition Desktop mounts as +# `desktop-office`. A carrier that ships the primary-runtime payload (for +# example a container image layer) names it with DSH_PRIMARY_RUNTIME; its +# sibling `office-skills/` directory carries the skill resources. Unset, both +# rows stay disabled and the profile is unchanged. +- insert: + - id: workspace-dependencies + name: '@deepseek-ai/dsh-tool-workspace-dependencies' + disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" + config: + source: !!js process.env.DSH_PRIMARY_RUNTIME ?? '' + + - id: skill-office + name: '@deepseek-ai/dsh-skill-office' + disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" + config: + # The payload's parent directory plus office-skills, without a path module in the expression scope. + assetRoot: !!js "process.env.DSH_PRIMARY_RUNTIME ? process.env.DSH_PRIMARY_RUNTIME.replace(/[\\\\/]+[^\\\\/]+[\\\\/]*$/, '') + '/office-skills' : ''" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 8d87fb7abe..362c579846 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -37,7 +37,9 @@ "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "commander": "^15.0.0" + "commander": "^15.0.0", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml new file mode 100644 index 0000000000..397d6f1a5b --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/skill/tool-workspace-dependencies/README.md +README.md: d39437c039ff479900f8192e666ab67f2dbedc89 +README.zh.md: c45cd21e810eb8a6a2c3e8bf22da4a5bdc8089ff diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md new file mode 100644 index 0000000000..d39437c039 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -0,0 +1,122 @@ +--- +description: "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload, used in place or installed under the Harness home." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-workspace-dependencies + +English | [中文](README.zh.md) + +## Summary + +Deployments that ship their own script runtimes (Desktop's primary runtime, or a container image layer) mount this tool so the agent can ask where the bundled Python, Node.js, and pnpm live instead of discovering a system interpreter. The tool returns absolute paths and recorded distribution versions; it changes neither `PATH` nor package-manager settings. The payload is either copied under the Harness home on first use (Desktop) or used where it lies (read-only carriers). + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the plugin beside the tool registry with the payload directory. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. + +### Minimal configuration + +```yaml +- name: '@deepseek-ai/dsh-tool-workspace-dependencies' + config: + source: /path/to/primary-runtime +``` + +| Field | Default | Meaning | +|---|---|---| +| `source` | required | Payload directory carrying `runtime.json` and `dependencies/`. | +| `root` | unset | Installation directory under the Harness home. Set: the payload is copied there on the first call and reused while `runtime.json` is unchanged. Unset: the payload is validated and used in place; nothing is copied. | + +### Payload layout + +`runtime.json` records `desktopVersion`, `platform` (`win32`, `darwin`, or `linux`), `arch`, the `components` versions (`python`, `numpy`, `pandas`; `node` and `pnpm` are optional), and optionally `payloadDigest` and `pythonPackages`. Entries live under `dependencies/`: `python/bin/python3` (`python/python.exe` on Windows) with `site-packages` beneath it, and, when declared, `node/bin/node` with `node/node_modules` and `pnpm/bin/pnpm.mjs`. A manifest whose platform or architecture differs from the running process is rejected. + +The `sdk` profile mounts this tool and the Office skills when `DSH_PRIMARY_RUNTIME` names a payload directory whose sibling `office-skills/` carries the skill resources; both rows stay disabled otherwise. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +`readPrimaryRuntime` validates the manifest and rejects duplicate normalized distribution names and numpy/pandas versions that disagree with the components table. `workspaceDependencyPaths` derives the platform-specific entries. `installPrimaryRuntime` copies into a staging directory, verifies every declared entry, and swaps it into place while retaining the previous tree on failure; `resolvePrimaryRuntime` verifies the same entries without copying. The tool memoizes the first successful preparation for the plugin's lifetime. + +| File | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | Manifest validation, path derivation, in-place and installed preparation, tool registration. | +| — | No runtime invariant companion is published: the payload manifest is validated on every preparation, and the tool registry owns registration lifecycle. | + +
+ +----- + + +## Further Exploration + +- [Office skills](../skill-office/README.md) — the workflows that call this tool for their interpreter. +- [Tool registry](../../core/tools/README.md) — registration and schemas. + +----- + + +## Model Experience + +### Tool schema + +#### What the model sees + +The model sees the generated [`load_workspace_dependencies` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-workspace-dependencies). + +#### Token effect + +Fixed schema cost per request where the tool is visible; the description names the bundled Office libraries so the model can choose the interpreter without loading a skill first. + +#### KV Cache effect + +Prefix-stable while the tool definition and visibility are unchanged. + +### Tool result + +#### What the model sees + +One JSON object with absolute `python` and `pythonPackages` paths, `pythonDistributions` from `runtime.json`, and `node`, `nodePackages`, and `pnpm` when the payload declares them. Repeated calls return the same object. + +#### Token effect + +A few hundred characters per call; paths dominate. + +#### KV Cache effect + +Append-only tool result in the turn history; no prompt section is added. + +## Known Limitations and Deferred Work + + + +- The payload build (`apps/desktop/scripts/prepare-primary-runtime.ts`) still targets Desktop; other carriers assemble the same layout themselves. +- Windows payloads used in place must already be executable from their carrier; the in-place mode performs no permission repair. + + +### Dev Note + +
+Working context for maintainers — click to expand + +Extracted from `apps/desktop-host` so the SDK profile can mount the same tool from a container image layer. The in-place mode and the optional Node.js/pnpm components exist for that carrier. + +
diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md new file mode 100644 index 0000000000..c45cd21e81 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -0,0 +1,122 @@ +--- +description: "load_workspace_dependencies 工具:返回随包附带的 Python、Node.js 与 pnpm 的绝对路径,payload 可原地使用或安装到 Harness home。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-workspace-dependencies + +[English](README.md) | 中文 + +## 概述 + +自带脚本运行时的部署(Desktop 的 primary runtime,或容器镜像层)挂载本工具,让 agent 询问随包的 Python、Node.js 与 pnpm 在哪里,而不是自行寻找系统解释器。工具返回绝对路径与记录的发行版版本,不改 `PATH`,不改包管理器设置。payload 或在首次调用时复制到 Harness home 下(Desktop),或原地使用(只读载体)。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +与工具注册表一起挂载,给出 payload 目录。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 + +### 最小配置 + +```yaml +- name: '@deepseek-ai/dsh-tool-workspace-dependencies' + config: + source: /path/to/primary-runtime +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `source` | 必填 | 含 `runtime.json` 与 `dependencies/` 的 payload 目录。 | +| `root` | 未设置 | Harness home 下的安装目录。设置时首次调用把 payload 复制过去,`runtime.json` 不变则复用;未设置时校验后原地使用,不复制。 | + +### payload 布局 + +`runtime.json` 记录 `desktopVersion`、`platform`(`win32`、`darwin` 或 `linux`)、`arch`、`components` 各版本(`python`、`numpy`、`pandas`;`node` 与 `pnpm` 可选),以及可选的 `payloadDigest` 与 `pythonPackages`。条目位于 `dependencies/`:`python/bin/python3`(Windows 为 `python/python.exe`)及其下的 `site-packages`;声明了才有的 `node/bin/node`、`node/node_modules` 与 `pnpm/bin/pnpm.mjs`。平台或架构与当前进程不符的清单被拒绝。 + +`sdk` profile 在 `DSH_PRIMARY_RUNTIME` 指向一个 payload 目录、且其同级 `office-skills/` 带有 skill 资源时挂载本工具与 Office skills;否则两行都保持禁用。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +`readPrimaryRuntime` 校验清单,拒绝归一化后重名的发行版以及与 components 表不一致的 numpy/pandas 版本。`workspaceDependencyPaths` 推导各平台的条目路径。`installPrimaryRuntime` 复制到暂存目录、校验每个声明的条目、再换入正式位置,失败时保留旧树;`resolvePrimaryRuntime` 做同样的校验但不复制。工具在插件生命周期内记住首次成功的结果。 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 清单校验、路径推导、原地与安装两种准备方式、工具注册。 | +| — | 不发布运行时不变量伴随入口:每次准备都校验 payload 清单,工具注册表负责注册生命周期。 | + +
+ +----- + + +## 延伸阅读 + +- [Office skills](../skill-office/README.zh.md)——调用本工具取解释器的工作流。 +- [工具注册表](../../core/tools/README.zh.md)——注册与 schema。 + +----- + + +## 模型体验 + +### 工具 schema + +#### 模型看到什么 + +模型看到生成的 [`load_workspace_dependencies` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-workspace-dependencies)。 + +#### Token 影响 + +工具可见的每个请求固定一份 schema 开销;描述里列出了随包的 Office 库,模型不必先加载 skill 就能选择解释器。 + +#### KV 缓存影响 + +工具定义与可见性不变时前缀稳定。 + +### 工具结果 + +#### 模型看到什么 + +一个 JSON 对象:绝对路径 `python` 与 `pythonPackages`、来自 `runtime.json` 的 `pythonDistributions`,以及 payload 声明了才有的 `node`、`nodePackages` 与 `pnpm`。重复调用返回同一对象。 + +#### Token 影响 + +每次调用几百个字符,以路径为主。 + +#### KV 缓存影响 + +作为工具结果追加进轮次历史;不增加提示词分区。 + +## 已知限制与暂缓事项 + + + +- payload 的构建脚本(`apps/desktop/scripts/prepare-primary-runtime.ts`)仍只面向 Desktop;其它载体按同一布局自行组装。 +- Windows 上原地使用的 payload 须在载体里已可执行;原地模式不修复权限。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +从 `apps/desktop-host` 抽出,让 SDK profile 能从容器镜像层挂载同一个工具。原地模式与可选的 Node.js/pnpm 组件都是为这种载体加的。 + +
diff --git a/packages/skill/tool-workspace-dependencies/package.json b/packages/skill/tool-workspace-dependencies/package.json new file mode 100644 index 0000000000..b3f26fcf86 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-tool-workspace-dependencies", + "description": "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/skill/tool-workspace-dependencies" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^" + } +} diff --git a/apps/desktop-host/src/primary-runtime.ts b/packages/skill/tool-workspace-dependencies/src/index.ts similarity index 52% rename from apps/desktop-host/src/primary-runtime.ts rename to packages/skill/tool-workspace-dependencies/src/index.ts index 130e75d9cc..4069234c3e 100644 --- a/apps/desktop-host/src/primary-runtime.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -1,9 +1,28 @@ -/** Offline installation and absolute paths for Desktop's bundled script dependencies. */ +/** Model-facing query for a bundled Python, Node.js, and pnpm payload, in place or installed under the Harness home. */ import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' import { dirname, join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' -/** Versions recorded by the Desktop build, independent of user-installed packages. */ +/** Cordis plugin identity. */ +export const name = 'tool-workspace-dependencies' +/** Registry the tool registers into. */ +export const inject = ['tools'] + +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} + +/** Versions recorded by the payload build, independent of user-installed packages. */ export interface PrimaryRuntimeManifest { readonly desktopVersion: string readonly platform: string @@ -14,8 +33,10 @@ export interface PrimaryRuntimeManifest { readonly pythonPackages?: Readonly> readonly components: { readonly python: string - readonly node: string - readonly pnpm: string + /** Absent when the payload ships no Node.js. */ + readonly node?: string + /** Absent when the payload ships no pnpm. */ + readonly pnpm?: string readonly numpy: string readonly pandas: string } @@ -24,14 +45,21 @@ export interface PrimaryRuntimeManifest { /** Absolute entry points and bundled versions; pnpm runs through the returned Node executable. */ export interface WorkspaceDependencies { readonly python: string - readonly node: string - readonly pnpm: string + readonly node?: string + readonly pnpm?: string readonly pythonPackages: string - readonly nodePackages: string + readonly nodePackages?: string /** Locked distribution versions; excludes packages users add to the installed environment. */ readonly pythonDistributions: Readonly> } +const VERSION = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u +const PLATFORMS = ['win32', 'darwin', 'linux'] + +function isVersion(value: unknown): boolean { + return typeof value === 'string' && VERSION.test(value) +} + /** * Read build metadata, rejecting duplicate normalized names and conflicting component/distribution versions. * @param root - Installed or bundled primary runtime directory. @@ -41,12 +69,13 @@ export async function readPrimaryRuntime(root: string): Promise - const components = record.components + const components = record.components as Record | null | undefined const packages = record.pythonPackages if (typeof record.desktopVersion !== 'string' || record.desktopVersion.length === 0 - || !['win32', 'darwin'].includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch)) + || !PLATFORMS.includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch)) || typeof components !== 'object' || components === null - || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record)[key]))) + || !['python', 'numpy', 'pandas'].every(key => isVersion(components[key])) + || !['node', 'pnpm'].every(key => components[key] === undefined || isVersion(components[key])) || (record.payloadDigest !== undefined && (typeof record.payloadDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.payloadDigest))) || (packages !== undefined && (typeof packages !== 'object' || packages === null || Array.isArray(packages) || !Object.entries(packages).every(([name, version]) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name) @@ -68,23 +97,32 @@ export async function readPrimaryRuntime(root: string): Promise path !== undefined) +} + async function exists(path: string): Promise { try { if ((await lstat(path)).isSymbolicLink()) throw new Error(`primary runtime: installation path is a filesystem link: ${path}`) @@ -95,15 +133,31 @@ async function exists(path: string): Promise { } } +async function compatibleManifest(source: string): Promise { + const manifest = await readPrimaryRuntime(source) + if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture') + return manifest +} + +/** + * Use a payload where it lies: validate its metadata and entries, copying nothing. + * @param source - Payload directory, typically a read-only carrier. + * @returns Paths into the payload itself. + */ +export async function resolvePrimaryRuntime(source: string): Promise { + const paths = workspaceDependencyPaths(source, await compatibleManifest(source)) + for (const path of payloadEntries(paths)) await stat(path) + return paths +} + /** * Install the application-owned payload locally, retaining a complete previous tree on copy failure. - * @param source - Payload carried by the current Desktop installation. + * @param source - Payload carried by the current installation. * @param root - Fixed primary runtime directory under the Harness home. * @returns Paths into the installed payload; no PATH or package-manager configuration is changed. */ export async function installPrimaryRuntime(source: string, root: string): Promise { - const manifest = await readPrimaryRuntime(source) - if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture') + const manifest = await compatibleManifest(source) await mkdir(dirname(root), { recursive: true }) const previous = `${root}.previous` await exists(previous) @@ -111,14 +165,14 @@ export async function installPrimaryRuntime(source: string, root: string): Promi if (!await exists(root) && await exists(previous)) await rename(previous, root) if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) { const paths = workspaceDependencyPaths(root, manifest) - for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) + for (const path of payloadEntries(paths)) await stat(path) return paths } const staging = await mkdtemp(join(dirname(root), '.primary-runtime-')) try { await cp(source, staging, { recursive: true, dereference: true }) const paths = workspaceDependencyPaths(staging, manifest) - for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) + for (const path of payloadEntries(paths)) await stat(path) await rm(previous, { recursive: true, force: true }) const replacing = await exists(root) if (replacing) await rename(root, previous) @@ -132,3 +186,44 @@ export async function installPrimaryRuntime(source: string, root: string): Promi } return workspaceDependencyPaths(root, manifest) } + +/** + * Register the read-only path query; the first invocation prepares (or merely validates) the payload. + * @param ctx - Tool registry owner. + * @param config - Payload location and optional installation directory. + */ +export function apply(ctx: Context, config: Config): void { + let preparation: Promise | undefined + ctx.effect(() => async () => { + // Tool execution reports preparation failures; disposal only waits for filesystem work to settle. + await preparation?.catch(() => undefined) + }) + ctx.tools.register(defineTool({ + name: 'load_workspace_dependencies', + description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', + parameters: {}, + output: { + schema: { + type: 'object', additionalProperties: false, + properties: { + python: { type: 'string', required: true }, + node: { type: 'string', description: 'Absent when the payload ships no Node.js.' }, + pnpm: { type: 'string', description: 'Absent when the payload ships no pnpm.' }, + pythonPackages: { type: 'string', required: true }, + nodePackages: { type: 'string', description: 'Absent when the payload ships no Node.js.' }, + pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' }, + }, + }, + render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }], + }, + execute: () => { + preparation ??= (config.root === undefined ? resolvePrimaryRuntime(config.source) : installPrimaryRuntime(config.source, config.root)) + .catch((error: unknown) => { + preparation = undefined + throw error + }) + return preparation + }, + presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }), + })) +} diff --git a/apps/desktop-host/tests/primary-runtime.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts similarity index 83% rename from apps/desktop-host/tests/primary-runtime.spec.ts rename to packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index cd1cff25c9..30b39ce477 100644 --- a/apps/desktop-host/tests/primary-runtime.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -10,8 +10,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' -import { installPrimaryRuntime, readPrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/primary-runtime.ts' -import * as workspaceDependencies from '../src/workspace-dependencies.ts' +import * as workspaceDependencies from '../src/index.ts' +import { installPrimaryRuntime, readPrimaryRuntime, resolvePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/index.ts' const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -22,22 +22,23 @@ async function fixture() { const source = join(directory, 'resources') const root = join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime') const manifest: PrimaryRuntimeManifest = { - desktopVersion: '1.0.0', platform: process.platform === 'win32' ? 'win32' : 'darwin', arch: process.arch, + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' }, pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, } const paths = workspaceDependencyPaths(source, manifest) for (const path of [paths.python, paths.node, paths.pnpm]) { + if (path === undefined) continue await mkdir(dirname(path), { recursive: true }) await writeFile(path, 'interpreter') } await mkdir(paths.pythonPackages, { recursive: true }) - await mkdir(paths.nodePackages, { recursive: true }) + if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) return { source, root, manifest, directory } } -it.each(['win32', 'darwin'])('returns %s interpreter and package paths', (platform) => { +it.each(['win32', 'darwin', 'linux'])('returns %s interpreter and package paths', (platform) => { const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' } } const paths = workspaceDependencyPaths('/runtime', manifest) expect(paths.pythonDistributions).toEqual({}) @@ -153,8 +154,8 @@ it.each([ await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') }) -it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exposes installed paths, and unregisters on disposal', async () => { - const { source, root, manifest, directory } = await fixture() +it('loads the real tool through Cordis, exposes payload paths in place, and unregisters on disposal', async () => { + const { source, manifest, directory } = await fixture() const ctx = new Context() try { ctx.baseUrl = pathToFileURL(directory).href + '/' @@ -171,7 +172,7 @@ it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exp }, } as unknown as NonNullable const config = join(directory, 'cordis.yml') - await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n root: ${JSON.stringify(root)}\n`) + await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n`) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() @@ -180,7 +181,7 @@ it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exp const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) }))) for (const result of results) { expect(result.isError).toBe(false) - expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(root, manifest), undefined, 2) }]) + expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(source, manifest), undefined, 2) }]) } expect(process.env).toEqual(environment) const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies') @@ -191,3 +192,28 @@ it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exp await ctx.fiber.dispose() } }) + +it('uses a payload in place without copying and tolerates a payload without Node.js or pnpm', async () => { + const { source, directory } = await fixture() + const manifest: PrimaryRuntimeManifest = { + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1' }, + pythonPackages: { 'python-docx': '1.2.0' }, + } + await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) + const paths = await resolvePrimaryRuntime(source) + expect(paths).toEqual({ + python: join(source, 'dependencies', 'python', ...(process.platform === 'win32' ? ['python.exe'] : ['bin', 'python3'])), + pythonPackages: join(source, 'dependencies', 'python', ...(process.platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages'), + pythonDistributions: { 'python-docx': '1.2.0' }, + }) + await expect(readFile(join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + await rm(paths.python) + await expect(resolvePrimaryRuntime(source)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('rejects a payload whose Node.js or pnpm version is malformed', async () => { + const { source, manifest } = await fixture() + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, node: 'latest' } })) + await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') +}) diff --git a/packages/skill/tool-workspace-dependencies/tsconfig.json b/packages/skill/tool-workspace-dependencies/tsconfig.json new file mode 100644 index 0000000000..28eabaa205 --- /dev/null +++ b/packages/skill/tool-workspace-dependencies/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e2ff0442..29df5ca749 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:^ version: link:packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:packages/skill/tool-workspace-dependencies '@deepseek-ai/dsh-web-fetch-http': specifier: workspace:^ version: link:packages/web/web-fetch-http @@ -297,6 +300,9 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../packages/skill/skill-office '@deepseek-ai/dsh-terminal': specifier: workspace:^ version: link:../../packages/terminal/terminal @@ -369,6 +375,9 @@ importers: '@deepseek-ai/dsh-tool-workflow': specifier: workspace:^ version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:../../packages/skill/tool-workspace-dependencies '@deepseek-ai/dsh-web-app': specifier: workspace:^ version: link:../../packages/bundle/web-app @@ -617,9 +626,9 @@ importers: '@deepseek-ai/dsh-skill-office': specifier: workspace:^ version: link:../../packages/skill/skill-office - '@deepseek-ai/dsh-tools': + '@deepseek-ai/dsh-tool-workspace-dependencies': specifier: workspace:^ - version: link:../../packages/core/tools + version: link:../../packages/skill/tool-workspace-dependencies apps/web: devDependencies: @@ -1942,6 +1951,12 @@ importers: '@deepseek-ai/dsh-sdk-jsonrpc-server': specifier: workspace:^ version: link:../../sdk/server + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../skill/skill-office + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:../../skill/tool-workspace-dependencies '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -10299,6 +10314,30 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/skill/tool-workspace-dependencies: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/spill/spill: devDependencies: '@deepseek-ai/cordis': @@ -12615,6 +12654,9 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-skill-office': + specifier: workspace:^ + version: link:../../packages/skill/skill-office '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../packages/spill/spill @@ -12714,6 +12756,9 @@ importers: '@deepseek-ai/dsh-tool-workflow': specifier: workspace:^ version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tool-workspace-dependencies': + specifier: workspace:^ + version: link:../../packages/skill/tool-workspace-dependencies '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../packages/core/tools diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 244cf218db..16b4ba9886 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -86,6 +86,8 @@ "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index d2c49aafc3..a301c69a22 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -75,6 +75,7 @@ import WorkflowEngine from '@deepseek-ai/dsh-workflow' import type { WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +import * as ToolWorkspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' import { githubSlug } from './verify-md-links.ts' /** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */ @@ -637,6 +638,17 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolWorkflow) }, }, + { + pkg: '@deepseek-ai/dsh-tool-workspace-dependencies', + dir: 'tool-workspace-dependencies', + source: 'packages/skill/tool-workspace-dependencies/src/index.ts', + requires: ['ctx.tools'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + // Schema harvest never prepares a payload; the directory need not exist. + await ctx.plugin(ToolWorkspaceDependencies, { source: resolve(root, '.tmp/tool-catalog/primary-runtime') }) + }, + }, { pkg: '@deepseek-ai/dsh-tool-web', dir: 'tool-web', diff --git a/snapshots/session/workspace-dependencies/cordis.snapshot.yml b/snapshots/session/workspace-dependencies/cordis.snapshot.yml index 0c8ada9cd0..6b88b3b3fe 100644 --- a/snapshots/session/workspace-dependencies/cordis.snapshot.yml +++ b/snapshots/session/workspace-dependencies/cordis.snapshot.yml @@ -46,7 +46,7 @@ - insert: - id: workspace-dependencies - name: ../../../apps/desktop-host/src/workspace-dependencies.ts + name: '@deepseek-ai/dsh-tool-workspace-dependencies' config: source: !!js process.cwd() + "/runtime-source" root: !!js process.cwd() + "/runtime-installed" diff --git a/snapshots/session/workspace-dependencies/cordis.yml b/snapshots/session/workspace-dependencies/cordis.yml index 00aae51666..295a5cc683 100644 --- a/snapshots/session/workspace-dependencies/cordis.yml +++ b/snapshots/session/workspace-dependencies/cordis.yml @@ -1,6 +1,6 @@ - insert: - id: workspace-dependencies - name: ../../../apps/desktop-host/src/workspace-dependencies.ts + name: '@deepseek-ai/dsh-tool-workspace-dependencies' config: source: !!js process.cwd() + '/runtime-source' root: !!js process.cwd() + '/runtime-installed' diff --git a/tsconfig.base.json b/tsconfig.base.json index 6ec529b39d..6bcc0d7a8d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -481,6 +481,7 @@ "@deepseek-ai/dsh-tool-web": ["./packages/web/tool-web/src"], "@deepseek-ai/dsh-tool-workflow": ["./packages/workflow/tool-workflow/src"], "@deepseek-ai/dsh-tool-workflow/invariant": ["./packages/workflow/tool-workflow/src/invariant.ts"], + "@deepseek-ai/dsh-tool-workspace-dependencies": ["./packages/skill/tool-workspace-dependencies/src"], "@deepseek-ai/dsh-tools": ["./packages/core/tools/src"], "@deepseek-ai/dsh-tools/invariant": ["./packages/core/tools/src/invariant.ts"], "@deepseek-ai/dsh-user-approval": ["./packages/interaction/user-approval/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 2b98af6f58..d3acdfc928 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -161,6 +161,7 @@ "references": [ { "path": "./packages/client/connection/tsconfig.host.json" }, { "path": "./packages/skill/skill-office" }, + { "path": "./packages/skill/tool-workspace-dependencies" }, { "path": "./packages/browser-use/browser-use" }, { "path": "./packages/experimental/browser-use-runtime" }, { "path": "./packages/experimental/browser-use-playwright-mcp" }, From 8fad9ccd8c8cc7e7d20775ebb167dbec8927f856 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 17 Sep 2026 21:10:12 +0800 Subject: [PATCH 02/87] feat(skill): complete shared Office runtime builds for SDK carriers --- ...2026-09-17-shared-office-runtime.i18n.yaml | 6 + .../2026-09-17-shared-office-runtime.md | 29 +++ .../2026-09-17-shared-office-runtime.zh.md | 29 +++ ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 2 +- ...-07-30-generated-third-party-notices.zh.md | 2 +- .github/workflows/ci.yml | 3 + THIRD_PARTY_NOTICES.md | 2 +- apps/cli/tests/desktop-host.e2e.ts | 3 +- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 104 ++++++++- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- .../scripts/prepare-primary-runtime.ts | 158 +------------ .../tests/primary-runtime-preparation.spec.ts | 137 +----------- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 + docs/module-graph.zh.md | 3 + lefthook.yml | 2 +- package.json | 6 + packages/bundle/sdk-app/README.i18n.yaml | 4 +- packages/bundle/sdk-app/README.md | 2 + packages/bundle/sdk-app/README.zh.md | 2 + packages/bundle/sdk-app/cordis.patch.yml | 9 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/skill/README.i18n.yaml | 4 +- packages/skill/README.md | 1 + packages/skill/README.zh.md | 1 + .../README.i18n.yaml | 4 +- .../tool-workspace-dependencies/README.md | 16 +- .../tool-workspace-dependencies/README.zh.md | 16 +- .../tool-workspace-dependencies/src/index.ts | 5 +- .../tests/tool-workspace-dependencies.spec.ts | 146 +++++++++++- pnpm-lock.yaml | 12 + scripts/gen-third-party-notices.spec.ts | 2 +- scripts/gen-third-party-notices.ts | 4 +- .../primary-runtime/lock.json | 24 ++ scripts/primary-runtime/prepare.spec.ts | 135 +++++++++++ scripts/primary-runtime/prepare.ts | 210 ++++++++++++++++++ .../primary-runtime/smoke.py | 0 40 files changed, 779 insertions(+), 325 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md rename apps/desktop/scripts/primary-runtime-lock.json => scripts/primary-runtime/lock.json (82%) create mode 100644 scripts/primary-runtime/prepare.spec.ts create mode 100644 scripts/primary-runtime/prepare.ts rename apps/desktop/scripts/smoke-primary-runtime.py => scripts/primary-runtime/smoke.py (100%) diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml new file mode 100644 index 0000000000..303cd65bd0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.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/implemented/architecture/2026-09-17-shared-office-runtime.md +2026-09-17-shared-office-runtime.md: 28daa6d3809705098722c41847fab9e3ee255bfe +2026-09-17-shared-office-runtime.zh.md: 09cc54e254546aa2787c37ab695c922ee86f0d66 diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md new file mode 100644 index 0000000000..28daa6d380 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md @@ -0,0 +1,29 @@ +# Agent Note: Shared Office runtime for Desktop and SDK + +Status: implemented + +English | [中文](2026-09-17-shared-office-runtime.zh.md) + +## Problem + +SDK deployments need the same Office authoring libraries as Desktop while keeping interpreter payloads in read-only container image layers. A Desktop-owned query and builder require downstream carriers to duplicate dependency locks and path conventions. + +## Decision + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md) owns manifest validation, interpreter paths, installation and the model query. It stays in the skill group beside its Office workflow consumer: the payload supplies those workflows, while the package exposes no workspace entity service. Desktop imports this package and retains copy-on-first-use installation. The SDK profile enables the query and Office skills only when its carrier sets `DSH_PRIMARY_RUNTIME`; it resolves the payload path and reads the payload in place. The common base and `sdk-minimal` do not enable Office implicitly. + +The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection. + +## Alternatives considered + +**Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query. + +**Enable Office in the common base.** A base deployment has no guaranteed bundled interpreter or resources. Explicit SDK carrier configuration makes that requirement observable without changing unrelated profiles. + +**Always copy to the Harness home.** Copying duplicates image-layer content, requires a writable destination and cannot serve the intended immutable carrier deployment. + +## Consequences + +Successful tool preparation is shared by concurrent calls for one plugin lifetime. Failed preparation is retryable; disposal removes registration and waits for outstanding filesystem work. Copy mode verifies a staged replacement and restores the preceding installation after a failed publication. In-place mode performs no payload writes. Paths are absolute, and runtime metadata and declared entries are checked before results are returned. + +SDK configuration changes require restart. Missing Office resources produce a startup warning and leave the optional skill provider inactive; runtime metadata and interpreter availability are validated on the first tool call. Linux ARM64 and musl builds remain outside the lock. Cross-target assembly verifies archive hashes but requires execution on the target host; the Linux artifact CI lane owns native Linux checks. Unit coverage pins manifest, installation and lifecycle behavior; SDK profile tests observe optional tools, skill discovery and results, and the recorded workspace-dependencies scenario pins model-visible validation errors. diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md new file mode 100644 index 0000000000..09cc54e254 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Desktop 与 SDK 共享 Office 运行时 + +Status: implemented + +[English](2026-09-17-shared-office-runtime.md) | 中文 + +## 问题 + +SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payload 保存在只读容器镜像层中。由 Desktop 持有查询工具和构建器,会要求下游载体重复维护依赖锁与路径约定。 + +## 决策 + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md) 持有清单校验、解释器路径、安装逻辑和模型查询。它保留在 skill 组中,与 Office 工作流消费者相邻:payload 服务于这些工作流,本包不提供 workspace 实体服务。Desktop 引用本包并保留首次使用时复制安装。SDK profile 仅在载体设置 `DSH_PRIMARY_RUNTIME` 时启用查询和 Office skills;它解析 payload 路径并原位读取。公共 base 与 `sdk-minimal` 不会隐式启用 Office。 + +[共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。 + +## 考虑过的替代方案 + +**将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。 + +**在公共 base 中启用 Office。** base 部署不保证提供内置解释器或资源。显式 SDK 载体配置可以让该要求可见,并保持其他 profile 的行为。 + +**总是复制到 Harness home。** 复制会重复镜像层内容、要求目标可写,也无法满足预期的不可变载体部署。 + +## 影响 + +同一插件生命周期内的并发调用共享成功的工具准备结果。准备失败后可重试;卸载移除工具注册并等待未完成的文件系统工作。复制模式验证暂存替换内容,并在发布失败后恢复原安装。原位模式不写入 payload。路径为绝对路径,返回结果前会检查运行时元数据和所声明的条目。 + +SDK 配置变更需要重启。缺少 Office 资源会产生启动警告,并使可选的 skill 提供方保持未激活;运行时元数据和解释器是否存在在首次工具调用时校验。Linux ARM64 与 musl 构建仍不在锁文件范围内。跨目标组装校验归档哈希,但需要在目标主机执行;Linux 产物 CI 负责本机 Linux 检查。单元覆盖固定清单、安装与生命周期行为;SDK profile 测试观察可选工具、skill 发现与结果,workspace-dependencies 录制场景固定模型可见的校验错误。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 11b7e048f6..888d5b333c 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 2b0bd012c23c873b3003916491daad467cf757b3 -2026-07-30-generated-third-party-notices.zh.md: 0c7b4df8edb60f2f70781e179edc7377da930128 +2026-07-30-generated-third-party-notices.md: c4458fac7de4d9e9b63b23855f95aaf69eb89f64 +2026-07-30-generated-third-party-notices.zh.md: f26d0d001ed06dd5bea00bbc5e3400b31b254081 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 2b0bd012c2..c4458fac7d 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -12,7 +12,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o ## Decision -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../apps/desktop/scripts/primary-runtime-lock.json). The root README pair links the file from its License section. +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. **Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 0c7b4df8ed..f26d0d001e 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../apps/desktop/scripts/primary-runtime-lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 **新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2139231c06..25ead2d3fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,6 +357,9 @@ jobs: if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium + - name: Build and execute the Linux Office runtime + run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads" + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7eb0c36e18..b2d8c6da7c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -246,7 +246,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm ## Desktop bundled Python distributions -The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [Desktop runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index a7326e5860..09c32d9bc5 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -13,7 +13,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur const modules = join(root, 'node_modules', '@deepseek-ai') const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url)) const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record } - const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools']) + const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths']) for (const name of Object.keys(manifest.dependencies)) { const destination = join(root, 'node_modules', name) mkdirSync(dirname(destination), { recursive: true }) @@ -22,7 +22,6 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } for (const [name, source] of [ ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`], - ['dsh-tools', 'export const defineTool = value => value'], ] as const) { writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}') writeFileSync(join(modules, name, 'index.js'), source) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 3601745871..14f640c63d 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -1,12 +1,14 @@ import { createServer } from 'node:http' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' const binScript = fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) @@ -407,3 +409,99 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 30_000) }) + +/** Materialize a native-layout payload whose paths the query can validate without executing binaries. */ +async function officeFixture(root: string, pythonOnly: boolean) { + const source = join(root, 'resources', 'primary-runtime') + const manifest: PrimaryRuntimeManifest = { + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1', + ...(pythonOnly ? {} : { node: '24.21.0', pnpm: '11.7.0' }) }, + pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, + } + const paths = workspaceDependencyPaths(source, manifest) + for (const file of [paths.python, paths.node, paths.pnpm]) { + if (file === undefined) continue + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, 'fixture interpreter') + } + await mkdir(paths.pythonPackages, { recursive: true }) + if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) + await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) + await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(root, 'resources', 'office-skills'), { recursive: true }) + return { source, paths } +} + +it.each(['unset', 'full', 'python-only', 'missing-assets'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-office-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const { source, paths } = await officeFixture(root, mode === 'python-only') + if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) + const requests: Record[] = [] + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8').on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as Record) + const query = requests.length === 1 && mode !== 'unset' + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(query + ? { type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} } + : { type: 'text', text: 'done' }, query ? 'tool_use' : 'end_turn')) + }) + }) + onTestFinished(() => new Promise((resolve) => { server.close(() => { resolve() }) })) + 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 did not bind') + const home = join(root, 'home') + const launch = resolveExampleLaunch({ + srcBin: binScript, tsconfigPath: join(repoRoot, 'tsconfig.json'), sourceImport: 'tsx/esm', + configArgs: ['--profile', 'sdk'], + env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' ? undefined : source + '/', + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, + }) + const child = execa(launch.command, launch.args, { cwd: repoRoot, env: launch.env, timeout: 60_000, reject: false }) + onTestFinished(async () => { child.kill('SIGKILL'); await child }) + let buffer = '', stderr = '' + const lines: string[] = [] + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + const parts = buffer.split('\n') + buffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + const send = (id: number, method: string, params?: object) => { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + return waitForLine(lines, value => value.id === id, () => stderr) + } + expect(await send(1, 'initialize', { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' })).toHaveProperty('result') + await send(2, 'session/prompt', { sessionId: 'office', contentBlocks: [{ type: 'text', text: 'Query workspace dependencies.' }] }) + await waitForLine(lines, (value) => { + const params = value.params as { sessionId?: string; event?: { type?: string } } | undefined + return value.method === 'session.event' && params?.sessionId === 'office' && params.event?.type === 'turn/end' + }, () => stderr) + const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) + expect(names.includes('load_workspace_dependencies')).toBe(mode !== 'unset') + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(mode === 'full' || mode === 'python-only') + } + if (mode !== 'unset') { + expect(requests).toHaveLength(2) + expect(requests[1]!.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'user', content: expect.arrayContaining([ + expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', + content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), + ]) as unknown }), + ])) + } + if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') + await send(3, 'shutdown') + const exit = await child + expect(exit.timedOut).toBe(false) + expect(exit.signal).toBeUndefined() + expect(exit.exitCode, stderr).toBe(0) + await expect(readFile(join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3341693119..a8137ddc21 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 14bbdb7ec0fddd47821c12ffe46549dd29abe0de -README.zh.md: 1e704900743ec6f339efdf849e60767c961d07ee +README.md: d6980c890901ae16e21a586105f71f3214ca5e30 +README.zh.md: da67581fe609115f03cf3bfb3a518c2db5e3b320 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 14bbdb7ec0..d6980c8909 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,7 +28,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 1e70490074..da67581fe6 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,7 +28,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index aa3ac650cb..9baff6254a 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -1,157 +1,26 @@ -/** Prepare pinned, relocatable script interpreters without installing into the build host. */ +/** Desktop resource locations and signing-aware verification for the shared runtime builder. */ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { cp } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import extractZip from 'extract-zip' -import { x as extractTar } from 'tar' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { preparePrimaryRuntime as preparePayload, smokePrimaryRuntime as smokePayload } from '../../../scripts/primary-runtime/prepare.ts' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' -import lock from './primary-runtime-lock.json' with { type: 'json' } /** - * Download or reuse an archive only when its bytes match the release lock. - * @param url - Locked archive URL. - * @param sha256 - Expected SHA-256 digest. - * @param cache - Download cache directory. - * @returns Verified local archive path. - */ -export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { - const destination = join(cache, sha256) - let bytes: Buffer - try { bytes = readFileSync(destination) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const response = await fetch(url) - if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) - bytes = Buffer.from(await response.arrayBuffer()) - } - if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) - writeFileSync(destination, bytes) - return destination -} - -async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { - const artifact = lock.targets[target] - const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` - return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache) -} - -/** - * Identify the inputs that assemble one target's payload, excluding unrelated target locks. - * @param target - Desktop target whose archives are installed. - * @param runtimeLock - Locked interpreter and wheel inputs. - * @param pnpmVersion - Package-manager version copied into the payload. - * @returns SHA-256 payload identity for installation reuse. - */ -export function primaryRuntimePayloadDigest(target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string): string { - const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock - // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. - // Bump format when extraction or assembly changes payload bytes without changing locked inputs. - return createHash('sha256').update(JSON.stringify({ - format: 2, target, pythonVersion, pythonRelease, nodeVersion, - artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, - })).digest('hex') -} - -/** - * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory. - * @param archive - Hash-verified wheel archive. - * @param destination - Absolute site-packages directory. - * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes. - */ -export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise { - await extractZip(archive, { - dir: destination, - onEntry: (entry) => { - const [directory, scheme] = entry.fileName.split('/') - if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') { - throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`) - } - }, - }) -} - -/** - * Copy the skill package's complete asset tree to ordinary filesystem resources. - * @param source - The package's assets directory. - * @param destination - Desktop runtime resource directory outside ASAR. - * @returns Resolves after replacing the external assets with the complete package tree. - */ -export async function prepareOfficeSkillAssets(source: string, destination: string): Promise { - rmSync(destination, { recursive: true, force: true }) - await cp(source, destination, { recursive: true, dereference: true }) -} - -/** - * Materialize the selected Desktop target's primary runtime in its build resources. + * Prepare Desktop resources for its selected packaging target. * @param options - Signed Windows packaging defers execution until its supervised signing stage. - * @returns Resolves after materialization and, unless deferred, native-target execution checks. + * @returns Resolves after preparation and, unless deferred, native-target execution checks. */ export async function preparePrimaryRuntime(options: { deferSmoke?: boolean } = {}): Promise { - const target = resolveDesktopBuildTarget() const paths = resolveDesktopTargetBuildPaths() - const artifact = lock.targets[target] - mkdirSync(paths.runtime, { recursive: true }) - mkdirSync(paths.downloads, { recursive: true }) - const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) - try { - const output = join(staging, 'payload') - const dependencies = join(output, 'dependencies') - mkdirSync(dependencies, { recursive: true }) - const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` - const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) - const unpackedNode = join(staging, 'node') - mkdirSync(unpackedNode) - if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) - else await extractTar({ file: nodeArchive, cwd: unpackedNode }) - const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) - mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) - mkdirSync(join(dependencies, 'node', 'node_modules')) - writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') - cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), - join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) - cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) - await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) - const require = createRequire(import.meta.url) - const pnpmManifest = require.resolve('pnpm') - const pnpm = JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string } - await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) - const desktop = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { version: string } - const manifest: PrimaryRuntimeManifest = { - desktopVersion: desktop.version, - platform: target === 'win-x64' ? 'win32' : 'darwin', - arch: target === 'mac-arm64' ? 'arm64' : 'x64', - payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpm.version), - pythonPackages: lock.pythonPackages, - components: { - python: lock.pythonVersion, node: lock.nodeVersion, pnpm: pnpm.version, - numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas, - }, - } - const entries = workspaceDependencyPaths(output, manifest) - for (const wheel of [...artifact.wheels, ...lock.wheels]) { - await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) - } - writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) - const destination = join(paths.runtime, 'primary-runtime') - rmSync(destination, { recursive: true, force: true }) - await cp(output, destination, { recursive: true, dereference: true }) - } finally { - rmSync(staging, { recursive: true, force: true }) - } - const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json')) - await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), - join(paths.runtime, 'office-skills')) + const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + await preparePayload({ target: resolveDesktopBuildTarget(), output: paths.runtime, cache: paths.downloads, version }) if (!options.deferSmoke) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) } /** - * Execute the native payload's interpreters, package manager and Python libraries. + * Verify Desktop's complete payload after preparation or platform signing. * @param root - Final payload directory, including any platform signatures. */ export function smokePrimaryRuntime(root: string): void { @@ -159,15 +28,8 @@ export function smokePrimaryRuntime(root: string): void { if (manifest.platform !== process.platform || manifest.arch !== process.arch) return if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) - // The Desktop payload always ships Node.js and pnpm; only other carriers may omit them. if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.') - const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const - execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) - execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), - manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) - execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) - execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options) - execFileSync(entries.node, [entries.pnpm, '--version'], options) + smokePayload(root, scrubWindowsSigningEnvironment(process.env)) } if (import.meta.main) await preparePrimaryRuntime() diff --git a/apps/desktop/tests/primary-runtime-preparation.spec.ts b/apps/desktop/tests/primary-runtime-preparation.spec.ts index 2e0f802af5..f0c08054f3 100644 --- a/apps/desktop/tests/primary-runtime-preparation.spec.ts +++ b/apps/desktop/tests/primary-runtime-preparation.spec.ts @@ -1,131 +1,18 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +/** Desktop carriers keep a complete interpreter payload even when SDK carriers choose Python-only. */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { zipSync } from 'fflate' +import { join } from 'node:path' import { expect, it } from 'vitest' -import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts' -import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' } +import { smokePrimaryRuntime } from '../scripts/prepare-primary-runtime.ts' -const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') -const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64') -const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64') - -it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => { - const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-') - const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => { - const [name, version] = basename(new URL(url).pathname).split('-') - return [normalize(name!), version] as const - }) - const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const) - expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length) - expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length) - expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared)) -}) - -it('keeps a target payload identity independent of other target archives', () => { - const changed = structuredClone(lock) - changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64) - expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')) - expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0')) -}) - -it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => { - const wheel = structuredClone(lock), distribution = structuredClone(lock) - wheel.wheels[0]!.sha256 = 'a'.repeat(64) - distribution.pythonPackages['python-docx'] = '1.2.1' - const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0') - expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original) -}) - -it('reports missing distribution metadata before trying to execute a stale native payload', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-')) +it('rejects a Python-only native Desktop payload before executing interpreters', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-python-only-')) try { - await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch })) - expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload') - } finally { await rm(root, { recursive: true, force: true }) } -}) - -it('extracts a hash-verified cached library without a Python installer or network request', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const hash = createHash('sha256').update(libraryWheel).digest('hex') - const archive = join(root, hash) - await writeFile(archive, libraryWheel) - expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') - await writeFile(archive, 'corrupt archive') - await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('fully extracts a large deflate-compressed wheel entry', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-')) - try { - const expected = Buffer.alloc(1024 * 1024) - // Seeded xorshift bytes keep the compressed entry larger than the stream buffers. - let state = 26 - for (let index = 0; index < expected.length; index++) { - state ^= state << 13 - state ^= state >>> 17 - state ^= state << 5 - expected[index] = state & 255 - } - const archive = join(root, 'large.whl') - await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 })) - await unpackPrimaryRuntimeWheel(archive, root) - expect(await readFile(join(root, 'large.bin'))).toEqual(expected) - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('retains auxiliary wheel scripts without generating command wrappers', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, relocatedWheel) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('rejects library files requiring an unsupported installation scheme', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, externalLibraryWheel) - await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('copies complete Office resources outside the application archive and removes obsolete assets', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-')) - try { - const source = join(root, 'package', 'assets') - const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills') - await mkdir(join(source, 'scripts'), { recursive: true }) - await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n') - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - await mkdir(join(source, name)) - await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`) - } - await prepareOfficeSkillAssets(source, destination) - await writeFile(join(destination, 'obsolete.py'), 'old helper') - await prepareOfficeSkillAssets(source, destination) - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`) - } - expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n') - await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' }) + await writeFile(join(root, 'runtime.json'), JSON.stringify({ + platform: process.platform, arch: process.arch, pythonPackages: {}, + components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1' }, + })) + expect(() => { smokePrimaryRuntime(root) }).toThrow('Desktop payload must declare node and pnpm') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a71977cacf..f1f14cf878 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870 -module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03 +module-graph.md: 29caf0ba5f4b00e6ef58a8687445d2b22713cf88 +module-graph.zh.md: 7a21ed9ba9a8893bb6e72317e04b15d84dd0ca03 diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ec3673173..29caf0ba5f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -777,6 +778,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -1508,6 +1510,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1dd43514ac..7a21ed9ba9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -67,6 +67,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -779,6 +780,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -1510,6 +1512,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | diff --git a/lefthook.yml b/lefthook.yml index 04106e1fd1..1d5c8898c6 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,apps/desktop/scripts/primary-runtime-lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/package.json b/package.json index 52cbabab43..e7310d9553 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "website" ], "scripts": { + "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", @@ -89,6 +90,7 @@ "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", + "pnpm": "11.7.0", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", @@ -219,7 +221,9 @@ "@yarnpkg/cli-dist": "4.17.1", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", + "extract-zip": "^2.0.1", "fast-check": "^4.8.0", + "fflate": "^0.8.2", "istanbul-lib-report": "^3.0.1", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", @@ -232,9 +236,11 @@ "micromark-extension-gfm": "^3.0.0", "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", + "pnpm": "11.7.0", "publint": "^0.3.21", "smol-toml": "^1.7.1", "spdx-expression-parse": "^5.0.0", + "tar": "^7.5.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ac62ef092e..616f7466f0 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/sdk-app/README.md -README.md: 0bac09b5a7e355434e35b04f4e9abd3e816505af -README.zh.md: e0e0d7e1e147c0a2dfaed7014ea0b3a0a9028f2b +README.md: acf038281ea56b435abfcaeb7e8afddcbfbfb7e4 +README.zh.md: 8e5c3f994b3d435bcfe6997f6bddc9d917f7307a diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index 0bac09b5a7..acf038281e 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -33,6 +33,8 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. +A deployment that sets `DSH_PRIMARY_RUNTIME` enables the [bundled Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package). Unset or empty configuration leaves both disabled. The carrier supplies a native payload and sibling `office-skills/`; the tool reads them in place, including from a read-only image layer. Missing skill resources produce a startup warning and leave Office skills unavailable. Invalid runtime metadata or missing interpreter entries fail the tool call. + ----- diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index e0e0d7e1e1..8e5c3f994b 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -33,6 +33,8 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 +部署设置 `DSH_PRIMARY_RUNTIME` 后会启用[内置 Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。未设置或为空时两者都禁用。载体提供本机 payload 与同级 `office-skills/`;工具原位读取,也支持只读镜像层。缺少 skill 资源会产生启动警告并使 Office skills 不可用。无效运行时元数据或缺失解释器条目会导致工具调用失败。 + ----- diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index e80f962953..94331513cf 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -27,18 +27,17 @@ # Bundled script runtime and Office skills, the composition Desktop mounts as # `desktop-office`. A carrier that ships the primary-runtime payload (for # example a container image layer) names it with DSH_PRIMARY_RUNTIME; its -# sibling `office-skills/` directory carries the skill resources. Unset, both -# rows stay disabled and the profile is unchanged. +# sibling `office-skills/` directory carries the skill resources. Missing resources +# produce a startup warning and leave the Office skill provider inactive. Unset, both rows stay disabled. - insert: - id: workspace-dependencies name: '@deepseek-ai/dsh-tool-workspace-dependencies' disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" config: - source: !!js process.env.DSH_PRIMARY_RUNTIME ?? '' + source: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? '')" - id: skill-office name: '@deepseek-ai/dsh-skill-office' disabled: !!js "!process.env.DSH_PRIMARY_RUNTIME" config: - # The payload's parent directory plus office-skills, without a path module in the expression scope. - assetRoot: !!js "process.env.DSH_PRIMARY_RUNTIME ? process.env.DSH_PRIMARY_RUNTIME.replace(/[\\\\/]+[^\\\\/]+[\\\\/]*$/, '') + '/office-skills' : ''" + assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index ba76ed68e4..0519f3b9cb 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -31,7 +31,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_agents', 'list_mcp_resource_templates', 'list_mcp_resources', - 'list_subagent_models', 'lsp', 'plugin_manager', 'present', 'pwsh', 'pwsh', 'ralph', + 'list_subagent_models', 'load_workspace_dependencies', 'lsp', 'plugin_manager', 'present', 'pwsh', 'pwsh', 'ralph', 'read', 'read_image', 'read_mcp_resource', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate', diff --git a/packages/skill/README.i18n.yaml b/packages/skill/README.i18n.yaml index b147ecb55a..bcc2766dbf 100644 --- a/packages/skill/README.i18n.yaml +++ b/packages/skill/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/README.md -README.md: 9546dfbfb2081480a1af8078d65481d0fc080ae3 -README.zh.md: eb1231b6fc7e110afdcc22fb25db3dd55b830092 +README.md: 75261bdca06b658f04fac17a3eab4306dbe5cecd +README.zh.md: 7d1c38a4fb456f26bf038c8ddfe6f8182d0058f8 diff --git a/packages/skill/README.md b/packages/skill/README.md index 9546dfbfb2..75261bdca0 100644 --- a/packages/skill/README.md +++ b/packages/skill/README.md @@ -29,6 +29,7 @@ The skill family lets agents and users discover and load reusable task instructi | [`skill-badge/`](skill-badge/README.md) | Bundles the official "powered by dsh" badge skill, disabled by default | registers on `ctx.skills` | | [`skill-office/`](skill-office/README.md) | Bundles Word, PowerPoint, and Excel workflows with structural file checks | registers on `ctx.skills` | | [`tool-skill/`](tool-skill/README.md) | Publishes the session skill catalog and the model-facing `skill` loader tool | registers on `ctx.tools` | +| [`tool-workspace-dependencies/`](tool-workspace-dependencies/README.md) | Reports bundled Office interpreter paths and versions for Desktop and SDK carriers | registers on `ctx.tools` | ----- diff --git a/packages/skill/README.zh.md b/packages/skill/README.zh.md index eb1231b6fc..7d1c38a4fb 100644 --- a/packages/skill/README.zh.md +++ b/packages/skill/README.zh.md @@ -29,6 +29,7 @@ skill 家族让 agent 和用户仅在需要时发现并加载可复用的任务 | [`skill-badge/`](skill-badge/README.zh.md) | 随包附带官方「powered by dsh」徽章 skill,默认禁用 | 注册到 `ctx.skills` | | [`skill-office/`](skill-office/README.zh.md) | 随包提供 Word、PowerPoint 和 Excel 工作流及文件结构检查 | 注册到 `ctx.skills` | | [`tool-skill/`](tool-skill/README.zh.md) | 发布会话 skill 目录与面向模型的 `skill` 加载工具 | 注册到 `ctx.tools` | +| [`tool-workspace-dependencies/`](tool-workspace-dependencies/README.zh.md) | 为 Desktop 与 SDK 载体报告内置 Office 解释器路径和版本 | 注册到 `ctx.tools` | ----- diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index 397d6f1a5b..9664f745e2 100644 --- a/packages/skill/tool-workspace-dependencies/README.i18n.yaml +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-workspace-dependencies/README.md -README.md: d39437c039ff479900f8192e666ab67f2dbedc89 -README.zh.md: c45cd21e810eb8a6a2c3e8bf22da4a5bdc8089ff +README.md: 1464e231980bc24559d5dfc2635d51e57bcfc116 +README.zh.md: 48520369dde7cb0a5fcfb8d53af687ce651c47d5 diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index d39437c039..1464e23198 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -37,14 +37,20 @@ Mount the plugin beside the tool registry with the payload directory. The bundle | Field | Default | Meaning | |---|---|---| -| `source` | required | Payload directory carrying `runtime.json` and `dependencies/`. | -| `root` | unset | Installation directory under the Harness home. Set: the payload is copied there on the first call and reused while `runtime.json` is unchanged. Unset: the payload is validated and used in place; nothing is copied. | +| `source` | required | Absolute payload directory carrying `runtime.json` and `dependencies/`. | +| `root` | unset | Absolute installation directory under the Harness home. Set: the payload is copied there on the first call and reused while `runtime.json` is unchanged. Unset: the payload is validated and used in place; nothing is copied. | ### Payload layout `runtime.json` records `desktopVersion`, `platform` (`win32`, `darwin`, or `linux`), `arch`, the `components` versions (`python`, `numpy`, `pandas`; `node` and `pnpm` are optional), and optionally `payloadDigest` and `pythonPackages`. Entries live under `dependencies/`: `python/bin/python3` (`python/python.exe` on Windows) with `site-packages` beneath it, and, when declared, `node/bin/node` with `node/node_modules` and `pnpm/bin/pnpm.mjs`. A manifest whose platform or architecture differs from the running process is rejected. -The `sdk` profile mounts this tool and the Office skills when `DSH_PRIMARY_RUNTIME` names a payload directory whose sibling `office-skills/` carries the skill resources; both rows stay disabled otherwise. +The `sdk` profile enables this tool and the Office skills when `DSH_PRIMARY_RUNTIME` is nonempty. The profile resolves that path from the launch directory and loads sibling `office-skills/` resources. Missing skill resources produce a startup warning and leave Office skills unavailable; invalid or incomplete runtime payloads fail the first tool call. Unset or empty configuration disables both rows. Profile patches can override either row, and changes require restarting the SDK process. + +### Build a carrier payload + +From a repository checkout with dependencies installed, `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office` writes `primary-runtime/` and `office-skills/`. The shared [download lock](../../../scripts/primary-runtime/lock.json) also covers `mac-arm64`, `mac-x64`, and `win-x64`. `--python-only` omits Node.js and pnpm; `--cache` selects the hash-verified archive cache. The entry executes interpreter and Office read/write checks only for a native target. Cross-target builds require those checks on the target host before deployment. + +A container can copy both directories into an immutable image layer and set `DSH_PRIMARY_RUNTIME` to the absolute `primary-runtime/` path. The SDK queries that payload in place. Desktop uses the same builder and retains its Harness-home installation and signing checks. ----- @@ -108,7 +114,7 @@ Append-only tool result in the turn history; no prompt section is added. -- The payload build (`apps/desktop/scripts/prepare-primary-runtime.ts`) still targets Desktop; other carriers assemble the same layout themselves. +- The Linux build target is GNU/Linux x64; Linux ARM64 and musl payloads are not locked. - Windows payloads used in place must already be executable from their carrier; the in-place mode performs no permission repair. @@ -117,6 +123,6 @@ Append-only tool result in the turn history; no prompt section is added.
Working context for maintainers — click to expand -Extracted from `apps/desktop-host` so the SDK profile can mount the same tool from a container image layer. The in-place mode and the optional Node.js/pnpm components exist for that carrier. +Placement and carrier choices are recorded in the [shared-runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md).
diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index c45cd21e81..48520369dd 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -37,14 +37,20 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `source` | 必填 | 含 `runtime.json` 与 `dependencies/` 的 payload 目录。 | -| `root` | 未设置 | Harness home 下的安装目录。设置时首次调用把 payload 复制过去,`runtime.json` 不变则复用;未设置时校验后原地使用,不复制。 | +| `source` | 必填 | 含 `runtime.json` 与 `dependencies/` 的 payload 绝对目录。 | +| `root` | 未设置 | Harness home 下的绝对安装目录。设置时首次调用把 payload 复制过去,`runtime.json` 不变则复用;未设置时校验后原地使用,不复制。 | ### payload 布局 `runtime.json` 记录 `desktopVersion`、`platform`(`win32`、`darwin` 或 `linux`)、`arch`、`components` 各版本(`python`、`numpy`、`pandas`;`node` 与 `pnpm` 可选),以及可选的 `payloadDigest` 与 `pythonPackages`。条目位于 `dependencies/`:`python/bin/python3`(Windows 为 `python/python.exe`)及其下的 `site-packages`;声明了才有的 `node/bin/node`、`node/node_modules` 与 `pnpm/bin/pnpm.mjs`。平台或架构与当前进程不符的清单被拒绝。 -`sdk` profile 在 `DSH_PRIMARY_RUNTIME` 指向一个 payload 目录、且其同级 `office-skills/` 带有 skill 资源时挂载本工具与 Office skills;否则两行都保持禁用。 +`DSH_PRIMARY_RUNTIME` 非空时,`sdk` profile 启用本工具和 Office skills。profile 从启动目录解析该路径,并加载同级 `office-skills/` 资源。缺少 skill 资源会产生启动警告并使 Office skills 不可用;无效或不完整的运行时 payload 会在首次工具调用时失败。未设置或为空时,两行都禁用。profile patch 可以覆盖任意一行,配置变更需要重启 SDK 进程。 + +### 构建载体 payload + +在已安装依赖的仓库 checkout 中运行 `CI=true pnpm run prepare:primary-runtime --target linux-x64 --output /tmp/dsh-office`,会生成 `primary-runtime/` 和 `office-skills/`。[共享下载锁](../../../scripts/primary-runtime/lock.json)还覆盖 `mac-arm64`、`mac-x64` 和 `win-x64`。`--python-only` 省略 Node.js 和 pnpm;`--cache` 选择经过哈希校验的归档缓存。入口仅对本机目标执行解释器与 Office 读写检查。跨目标构建必须在部署前到目标主机执行这些检查。 + +容器可将这两个目录复制到不可变镜像层,并将 `DSH_PRIMARY_RUNTIME` 设为 `primary-runtime/` 的绝对路径。SDK 原位查询该 payload。Desktop 使用同一构建器,并保留 Harness-home 安装与签名检查。 ----- @@ -108,7 +114,7 @@ kind: "package-reference" -- payload 的构建脚本(`apps/desktop/scripts/prepare-primary-runtime.ts`)仍只面向 Desktop;其它载体按同一布局自行组装。 +- Linux 构建目标为 GNU/Linux x64;尚未锁定 Linux ARM64 与 musl payload。 - Windows 上原地使用的 payload 须在载体里已可执行;原地模式不修复权限。 @@ -117,6 +123,6 @@ kind: "package-reference"
维护者的工作上下文——点击展开 -从 `apps/desktop-host` 抽出,让 SDK profile 能从容器镜像层挂载同一个工具。原地模式与可选的 Node.js/pnpm 组件都是为这种载体加的。 +包归属与载体选择记录在[共享运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md) 中。
diff --git a/packages/skill/tool-workspace-dependencies/src/index.ts b/packages/skill/tool-workspace-dependencies/src/index.ts index 4069234c3e..e957ceab7d 100644 --- a/packages/skill/tool-workspace-dependencies/src/index.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -1,7 +1,7 @@ /** Model-facing query for a bundled Python, Node.js, and pnpm payload, in place or installed under the Harness home. */ import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname, isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -193,6 +193,9 @@ export async function installPrimaryRuntime(source: string, root: string): Promi * @param config - Payload location and optional installation directory. */ export function apply(ctx: Context, config: Config): void { + if (!isAbsolute(config.source) || (config.root !== undefined && !isAbsolute(config.root))) { + throw new Error('workspace dependencies: source and root must be absolute paths') + } let preparation: Promise | undefined ctx.effect(() => async () => { // Tool execution reports preparation failures; disposal only waits for filesystem work to settle. diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index 30b39ce477..73f12ab47b 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -1,8 +1,9 @@ +import * as fs from 'node:fs/promises' import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, expect, it } from 'vitest' +import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' @@ -13,8 +14,16 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import * as workspaceDependencies from '../src/index.ts' import { installPrimaryRuntime, readPrimaryRuntime, resolvePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/index.ts' +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rename: vi.fn(actual.rename), lstat: vi.fn(actual.lstat), cp: vi.fn(actual.cp) } +}) + const roots: string[] = [] -afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) +afterEach(async () => { + vi.resetAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) async function fixture() { const directory = await mkdtemp(join(tmpdir(), 'dsh-primary-runtime-')) @@ -46,7 +55,7 @@ it.each(['win32', 'darwin', 'linux'])('returns %s interpreter and package paths' expect(paths.pythonPackages).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages')) }) -it.skipIf(process.platform === 'linux')('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => { +it('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => { const { source, root, manifest } = await fixture() const environment = { ...process.env } const installed = await installPrimaryRuntime(source, root) @@ -57,7 +66,7 @@ it.skipIf(process.platform === 'linux')('installs offline, reuses the same relea expect(process.env).toEqual(environment) }) -it.skipIf(process.platform === 'linux')('replaces release components and recovers an interrupted directory swap', async () => { +it('replaces release components and recovers an interrupted directory swap', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) await rename(root, `${root}.previous`) @@ -66,7 +75,7 @@ it.skipIf(process.platform === 'linux')('replaces release components and recover expect((await readPrimaryRuntime(root)).desktopVersion).toBe('2.0.0') }) -it.skipIf(process.platform === 'linux')('replaces dependencies when the locked payload changes without a Desktop version change', async () => { +it('replaces dependencies when the locked payload changes without a Desktop version change', async () => { const { source, root, manifest } = await fixture() const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.1.2' } } await writeFile(join(source, 'runtime.json'), JSON.stringify(first)) @@ -81,7 +90,7 @@ it.skipIf(process.platform === 'linux')('replaces dependencies when the locked p await expect(readFile(join(installed.pythonPackages, 'old-package.py'))).rejects.toMatchObject({ code: 'ENOENT' }) }) -it.skipIf(process.platform === 'linux')('upgrades a release manifest without a payload digest', async () => { +it('upgrades a release manifest without a payload digest', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } })) @@ -89,7 +98,7 @@ it.skipIf(process.platform === 'linux')('upgrades a release manifest without a p expect((await readPrimaryRuntime(root)).payloadDigest).toBe('a'.repeat(64)) }) -it.skipIf(process.platform === 'linux')('replaces changed payload bytes when only the digest changes', async () => { +it('replaces changed payload bytes when only the digest changes', async () => { const { source, root, manifest } = await fixture() const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } } const sourceFile = join(workspaceDependencyPaths(source, first).pythonPackages, 'library.py') @@ -103,7 +112,7 @@ it.skipIf(process.platform === 'linux')('replaces changed payload bytes when onl expect((await readPrimaryRuntime(root)).pythonPackages).toEqual(first.pythonPackages) }) -it.skipIf(process.platform === 'linux')('keeps the installed release when the replacement payload is incomplete', async () => { +it('keeps the installed release when the replacement payload is incomplete', async () => { const { source, root, manifest } = await fixture() await installPrimaryRuntime(source, root) await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) @@ -112,7 +121,7 @@ it.skipIf(process.platform === 'linux')('keeps the installed release when the re expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0') }) -it.skipIf(process.platform === 'linux')('refuses linked installation directories without modifying their targets', async () => { +it('refuses linked installation directories without modifying their targets', async () => { const { source, root, directory } = await fixture() const outside = join(directory, 'outside') await mkdir(outside) @@ -217,3 +226,122 @@ it('rejects a payload whose Node.js or pnpm version is malformed', async () => { await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, node: 'latest' } })) await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') }) + +/** Create the real scoped tool registry for lifecycle and failure-path assertions. */ +async function tool(config: workspaceDependencies.Config) { + const ctx = new Context() + try { + await ctx.plugin(AgentRegistry) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const fiber = ctx.plugin(workspaceDependencies, config) + await fiber + const execute = () => ctx.tools.execute({ + signal: new AbortController().signal, name: 'load_workspace_dependencies', + arguments: {}, callId: ToolCallId('dependencies'), + }) + return { ctx, fiber, execute } + } catch (error) { + await ctx.fiber.dispose() + throw error + } +} + +it.each([null, [], { desktopVersion: '' }, { platform: 'unsupported' }, { arch: 'ia32' }, + { components: null }, { components: { python: 3 } }, { payloadDigest: 1 }, + { pythonPackages: null }, { pythonPackages: { numpy: 3 } }, +])('rejects malformed manifest fields: %j', async (invalid) => { + const { source, manifest } = await fixture() + const value = invalid === null || Array.isArray(invalid) ? invalid : { ...manifest, ...invalid } + await writeFile(join(source, 'runtime.json'), JSON.stringify(value)) + await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') +}) + +it('reads legacy metadata without a distribution map and rejects incompatible platforms', async () => { + const { source, manifest } = await fixture() + const { pythonPackages: _packages, ...legacy } = manifest + await writeFile(join(source, 'runtime.json'), JSON.stringify(legacy)) + expect((await resolvePrimaryRuntime(source)).pythonDistributions).toEqual({}) + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, platform: process.platform === 'linux' ? 'darwin' : 'linux' })) + await expect(resolvePrimaryRuntime(source)).rejects.toThrow('incompatible') +}) + +it('propagates filesystem access failures without touching the installation', async () => { + const { source, root } = await fixture() + vi.mocked(fs.lstat).mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' })) + await expect(installPrimaryRuntime(source, root)).rejects.toThrow('denied') + await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([false, true])('preserves the previous installation if publication fails (previous: %s)', async (previous) => { + const { source, root, manifest } = await fixture() + if (previous) { + await installPrimaryRuntime(source, root) + await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) + } + const actual = await vi.importActual('node:fs/promises') + vi.mocked(fs.rename).mockImplementationOnce(previous ? actual.rename : async () => { throw new Error('publication failed') }) + if (previous) vi.mocked(fs.rename).mockRejectedValueOnce(new Error('publication failed')) + await expect(installPrimaryRuntime(source, root)).rejects.toThrow('publication failed') + if (previous) expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0') + else await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await fs.readdir(dirname(root))).filter(name => name.startsWith('.primary-runtime-'))).toEqual([]) +}) + +it('retries a failed tool preparation and renders its call presentation', async () => { + const { source, root } = await fixture() + const metadata = await readFile(join(source, 'runtime.json')) + await rm(join(source, 'runtime.json')) + const { ctx, execute } = await tool({ source, root }) + try { + expect((await execute()).isError).toBe(true) + await writeFile(join(source, 'runtime.json'), metadata) + expect((await execute()).isError).toBe(false) + expect(await readFile(join(root, 'runtime.json'))).toEqual(metadata) + const descriptor = ctx.tools.get('load_workspace_dependencies')! + expect(descriptor.presentCall?.({})).toEqual({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }) + } finally { await ctx.fiber.dispose() } +}) + +it.each([false, true])('disposal waits for pending filesystem preparation (failure: %s)', async (failure) => { + const { source, root } = await fixture() + const actual = await vi.importActual('node:fs/promises') + let release!: () => void + let copying!: () => void + const entered = new Promise((resolve) => { copying = resolve }) + const proceed = new Promise((resolve) => { release = resolve }) + vi.mocked(fs.cp).mockImplementationOnce(async (...args) => { + copying() + await proceed + if (failure) throw new Error('copy failed') + await actual.cp(...args) + }) + const { ctx, fiber, execute } = await tool({ source, root }) + const pending = execute() + try { + await entered + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release() + await disposal + expect((await pending).isError).toBe(failure) + expect(ctx.tools.schemas().some(value => value.name === 'load_workspace_dependencies')).toBe(false) + } finally { + release() + await pending + await ctx.fiber.dispose() + } +}) + +it('disposes an unused tool without preparing the payload', async () => { + const { source, root } = await fixture() + const { ctx } = await tool({ source, root }) + await ctx.fiber.dispose() + await expect(readFile(join(root, 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([{ source: 'relative' }, { source: process.cwd(), root: 'relative' }])('rejects relative runtime configuration at plugin load: %j', async (config) => { + await expect(tool(config)).rejects.toThrow('must be absolute paths') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29df5ca749..4368a35618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,9 +99,15 @@ importers: execa: specifier: ^10.0.0 version: 10.0.0 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1(supports-color@9.4.0) fast-check: specifier: ^4.8.0 version: 4.8.0 + fflate: + specifier: ^0.8.2 + version: 0.8.3 istanbul-lib-report: specifier: ^3.0.1 version: 3.0.1 @@ -138,6 +144,9 @@ importers: oxlint-tsgolint: specifier: 7.0.2001 version: 7.0.2001 + pnpm: + specifier: 11.7.0 + version: 11.7.0 publint: specifier: ^0.3.21 version: 0.3.21 @@ -147,6 +156,9 @@ importers: spdx-expression-parse: specifier: ^5.0.0 version: 5.0.0 + tar: + specifier: ^7.5.0 + version: 7.5.22 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 3ab18f9291..e4a452d712 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' } +import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { CLAUDE_AGENT_SDK_PACKAGE, assertRuntimeLicenses, diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c77fbd6b38..c65dbdc6f1 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -13,7 +13,7 @@ import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' -import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' } +import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') @@ -819,7 +819,7 @@ ${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.r ## Desktop bundled Python distributions -The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [Desktop runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/desktop/scripts/primary-runtime-lock.json b/scripts/primary-runtime/lock.json similarity index 82% rename from apps/desktop/scripts/primary-runtime-lock.json rename to scripts/primary-runtime/lock.json index 4dcf73c886..060d2018a6 100644 --- a/apps/desktop/scripts/primary-runtime-lock.json +++ b/scripts/primary-runtime/lock.json @@ -74,6 +74,30 @@ "sha256": "623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d" } ] + }, + "linux-x64": { + "nodeArchive": "linux-x64.tar.gz", + "nodeSha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", + "pythonTarget": "x86_64-unknown-linux-gnu", + "pythonSha256": "72748da13197c1fb161e3afeef20a6a385ff24f2165e6e2758e47008e7faba4c", + "wheels": [ + { + "url": "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28" + }, + { + "url": "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4" + }, + { + "url": "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91" + }, + { + "url": "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11" + } + ] } }, "wheels": [ diff --git a/scripts/primary-runtime/prepare.spec.ts b/scripts/primary-runtime/prepare.spec.ts new file mode 100644 index 0000000000..fd050d9a62 --- /dev/null +++ b/scripts/primary-runtime/prepare.spec.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { zipSync } from 'fflate' +import { expect, it } from 'vitest' +import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from './prepare.ts' +import lock from './lock.json' with { type: 'json' } + +const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') +const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64') +const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64') + +it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => { + const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-') + const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => { + const [name, version] = basename(new URL(url).pathname).split('-') + return [normalize(name!), version] as const + }) + const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const) + expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length) + expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length) + expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared)) +}) + +it('keeps a target payload identity independent of other target archives', () => { + const changed = structuredClone(lock) + changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64) + expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')) + expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0')) +}) + +it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => { + const wheel = structuredClone(lock), distribution = structuredClone(lock) + wheel.wheels[0]!.sha256 = 'a'.repeat(64) + distribution.pythonPackages['python-docx'] = '1.2.1' + const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0') + expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original) + expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original) + expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original) +}) + +it('reports missing distribution metadata before trying to execute a stale native payload', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-')) + try { + await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch })) + expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload') + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('extracts a hash-verified cached library without a Python installer or network request', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) + try { + const hash = createHash('sha256').update(libraryWheel).digest('hex') + const archive = join(root, hash) + await writeFile(archive, libraryWheel) + expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) + await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) + expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') + await writeFile(archive, 'corrupt archive') + await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('fully extracts a large deflate-compressed wheel entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-')) + try { + const expected = Buffer.alloc(1024 * 1024) + // Seeded xorshift bytes keep the compressed entry larger than the stream buffers. + let state = 26 + for (let index = 0; index < expected.length; index++) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + expected[index] = state & 255 + } + const archive = join(root, 'large.whl') + await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 })) + await unpackPrimaryRuntimeWheel(archive, root) + expect(await readFile(join(root, 'large.bin'))).toEqual(expected) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('retains auxiliary wheel scripts without generating command wrappers', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) + try { + const archive = join(root, 'relocated.whl') + await writeFile(archive, relocatedWheel) + await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) + expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('rejects library files requiring an unsupported installation scheme', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) + try { + const archive = join(root, 'relocated.whl') + await writeFile(archive, externalLibraryWheel) + await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('copies complete Office resources outside the application archive and removes obsolete assets', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-')) + try { + const source = join(root, 'package', 'assets') + const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills') + await mkdir(join(source, 'scripts'), { recursive: true }) + await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n') + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + await mkdir(join(source, name)) + await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`) + } + await prepareOfficeSkillAssets(source, destination) + await writeFile(join(destination, 'obsolete.py'), 'old helper') + await prepareOfficeSkillAssets(source, destination) + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`) + } + expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n') + await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('gives Python-only payloads a distinct identity', () => { + expect(primaryRuntimePayloadDigest('linux-x64', lock, undefined)).not.toBe(primaryRuntimePayloadDigest('linux-x64', lock, '11.7.0')) +}) diff --git a/scripts/primary-runtime/prepare.ts b/scripts/primary-runtime/prepare.ts new file mode 100644 index 0000000000..b84769dfd6 --- /dev/null +++ b/scripts/primary-runtime/prepare.ts @@ -0,0 +1,210 @@ +/** Prepare pinned, relocatable script interpreters without installing into the build host. */ + +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { cp } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import extractZip from 'extract-zip' +import { x as extractTar } from 'tar' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts' +import lock from './lock.json' with { type: 'json' } + +/** + * Download or reuse an archive only when its bytes match the release lock. + * @param url - Locked archive URL. + * @param sha256 - Expected SHA-256 digest. + * @param cache - Download cache directory. + * @returns Verified local archive path. + */ +export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { + const destination = join(cache, sha256) + let bytes: Buffer + try { bytes = readFileSync(destination) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const response = await fetch(url) + if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) + bytes = Buffer.from(await response.arrayBuffer()) + } + if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) + writeFileSync(destination, bytes) + return destination +} + +async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { + const artifact = lock.targets[target] + const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` + return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache) +} + +/** + * Identify the inputs that assemble one target's payload, excluding unrelated target locks. + * @param target - Runtime target whose archives are installed. + * @param runtimeLock - Locked interpreter and wheel inputs. + * @param pnpmVersion - Package-manager version copied into the payload. + * @returns SHA-256 payload identity for installation reuse. + */ +export function primaryRuntimePayloadDigest( + target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string | undefined, +): string { + const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock + // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. + // Bump format when extraction or assembly changes payload bytes without changing locked inputs. + return createHash('sha256').update(JSON.stringify({ + format: 3, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion, + artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, + })).digest('hex') +} + +/** + * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory. + * @param archive - Hash-verified wheel archive. + * @param destination - Absolute site-packages directory. + * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes. + */ +export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise { + await extractZip(archive, { + dir: destination, + onEntry: (entry) => { + const [directory, scheme] = entry.fileName.split('/') + if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') { + throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`) + } + }, + }) +} + +/** + * Copy the skill package's complete asset tree to ordinary filesystem resources. + * @param source - The package's assets directory. + * @param destination - External Office skill resource directory. + * @returns Resolves after replacing the external assets with the complete package tree. + */ +export async function prepareOfficeSkillAssets(source: string, destination: string): Promise { + rmSync(destination, { recursive: true, force: true }) + await cp(source, destination, { recursive: true, dereference: true }) +} + +/** A locked interpreter and wheel target. */ +export type PrimaryRuntimeTarget = keyof typeof lock.targets + +/** Build-only inputs shared by Desktop and SDK carriers. */ +export interface PreparePrimaryRuntimeOptions { + /** Target whose archives and wheels are downloaded. */ + readonly target: PrimaryRuntimeTarget + /** Resource directory receiving primary-runtime/ and office-skills/. */ + readonly output: string + /** SHA-256-addressed archive cache. */ + readonly cache: string + /** Carrier release recorded in the legacy desktopVersion manifest field. */ + readonly version: string + /** Omit Node.js and pnpm for carriers providing only Python. */ + readonly pythonOnly?: boolean +} + +/** + * Materialize locked interpreters, libraries and Office resources without executing target code. + * @param options - Explicit target and carrier-owned output locations. + * @returns Resolves after the complete payload and skills have been copied to the output directory. + */ +export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOptions): Promise { + const { target } = options + const paths = { runtime: resolve(options.output), downloads: resolve(options.cache) } + const artifact = lock.targets[target] + mkdirSync(paths.runtime, { recursive: true }) + mkdirSync(paths.downloads, { recursive: true }) + const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) + try { + const output = join(staging, 'payload') + const dependencies = join(output, 'dependencies') + mkdirSync(dependencies, { recursive: true }) + let pnpmVersion: string | undefined + if (!options.pythonOnly) { + const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` + const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) + const unpackedNode = join(staging, 'node') + mkdirSync(unpackedNode) + if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) + else await extractTar({ file: nodeArchive, cwd: unpackedNode }) + const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) + mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) + mkdirSync(join(dependencies, 'node', 'node_modules')) + writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') + cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), + join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) + cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) + const require = createRequire(import.meta.url) + const pnpmManifest = require.resolve('pnpm') + pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version + await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) + } + await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) + const manifest: PrimaryRuntimeManifest = { + desktopVersion: options.version, + platform: target === 'win-x64' ? 'win32' : target === 'linux-x64' ? 'linux' : 'darwin', + arch: target === 'mac-arm64' ? 'arm64' : 'x64', + payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpmVersion), + pythonPackages: lock.pythonPackages, + components: { + python: lock.pythonVersion, + ...(pnpmVersion === undefined ? {} : { node: lock.nodeVersion, pnpm: pnpmVersion }), + numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas, + }, + } + const entries = workspaceDependencyPaths(output, manifest) + for (const wheel of [...artifact.wheels, ...lock.wheels]) { + await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) + } + writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) + const destination = join(paths.runtime, 'primary-runtime') + rmSync(destination, { recursive: true, force: true }) + await cp(output, destination, { recursive: true, dereference: true }) + } finally { + rmSync(staging, { recursive: true, force: true }) + } + const require = createRequire(import.meta.url) + await prepareOfficeSkillAssets(join(dirname(require.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), + join(paths.runtime, 'office-skills')) +} + +/** + * Execute the native payload's interpreters, package manager and Python libraries. + * @param root - Final payload directory, including any platform signatures. + * @param environment - Scrubbed subprocess environment; defaults to excluding credential-shaped names. + */ +export function smokePrimaryRuntime(root: string, environment: NodeJS.ProcessEnv = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)), +)): void { + const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest + if (manifest.platform !== process.platform || manifest.arch !== process.arch) return + if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') + const entries = workspaceDependencyPaths(root, manifest) + const options = { stdio: 'inherit', timeout: 120_000, env: environment } as const + execFileSync(entries.python, ['-I', '-B', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) + execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke.py'), JSON.stringify(manifest.pythonPackages), + manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) + execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) + if (entries.node !== undefined) execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options) + if (entries.pnpm !== undefined && entries.node !== undefined) execFileSync(entries.node, [entries.pnpm, '--version'], options) +} + +if (import.meta.main) { + const { values } = parseArgs({ options: { + target: { type: 'string' }, output: { type: 'string' }, cache: { type: 'string' }, + 'python-only': { type: 'boolean', default: false }, + } }) + if (!values.target || !Object.hasOwn(lock.targets, values.target) || !values.output) { + throw new Error(`Usage: pnpm run prepare:primary-runtime --target <${Object.keys(lock.targets).join('|')}> --output [--cache ] [--python-only]`) + } + const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string } + const output = resolve(values.output) + await preparePrimaryRuntime({ + target: values.target as PrimaryRuntimeTarget, output, + cache: values.cache ?? join(tmpdir(), 'dsh-primary-runtime-downloads'), version, + pythonOnly: values['python-only'], + }) + smokePrimaryRuntime(join(output, 'primary-runtime')) +} diff --git a/apps/desktop/scripts/smoke-primary-runtime.py b/scripts/primary-runtime/smoke.py similarity index 100% rename from apps/desktop/scripts/smoke-primary-runtime.py rename to scripts/primary-runtime/smoke.py From be8040d042c75848f24c379cb8fa705084a4bf60 Mon Sep 17 00:00:00 2001 From: fz Date: Thu, 17 Sep 2026 21:15:55 +0800 Subject: [PATCH 03/87] test(sdk): align Office checks with runtime profile resolution --- apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 5e95ce02f0..a27258fc4c 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -8,7 +8,6 @@ import { zstdDecompress } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { execa } from 'execa' import { describe, expect, it, onTestFinished } from 'vitest' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) @@ -456,14 +455,14 @@ it.each(['unset', 'full', 'python-only', 'missing-assets'] as const)('composes O const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') const home = join(root, 'home') - const launch = resolveExampleLaunch({ - srcBin: binScript, tsconfigPath: join(repoRoot, 'tsconfig.json'), sourceImport: 'tsx/esm', + const officeLaunch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', configArgs: ['--profile', 'sdk'], env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' ? undefined : source + '/', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, }) - const child = execa(launch.command, launch.args, { cwd: repoRoot, env: launch.env, timeout: 60_000, reject: false }) + const child = execa(officeLaunch.command, officeLaunch.args, { cwd: repoRoot, env: officeLaunch.env, timeout: 60_000, reject: false }) onTestFinished(async () => { child.kill('SIGKILL'); await child }) let buffer = '', stderr = '' const lines: string[] = [] From 4d34cd87dd13fca2b4cc7f7c77c0e1e83bf4c8a2 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 18 Sep 2026 11:52:04 +0800 Subject: [PATCH 04/87] fix(skill): address shared runtime review findings --- ...2026-09-17-shared-office-runtime.i18n.yaml | 4 +-- .../2026-09-17-shared-office-runtime.md | 2 ++ .../2026-09-17-shared-office-runtime.zh.md | 2 ++ ...26-09-14-desktop-primary-runtime.i18n.yaml | 4 +-- .../2026-09-14-desktop-primary-runtime.md | 4 ++- .../2026-09-14-desktop-primary-runtime.zh.md | 4 ++- ...30-generated-third-party-notices.i18n.yaml | 4 +-- ...026-07-30-generated-third-party-notices.md | 6 ++-- ...-07-30-generated-third-party-notices.zh.md | 6 ++-- THIRD_PARTY_NOTICES.md | 6 ++-- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/package.json | 2 -- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 +-- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- package.json | 1 - .../README.i18n.yaml | 4 +-- .../tool-workspace-dependencies/README.md | 2 +- .../tool-workspace-dependencies/README.zh.md | 2 +- .../tool-workspace-dependencies/package.json | 5 +++- .../tool-workspace-dependencies/src/index.ts | 15 +++++++--- .../tests/tool-workspace-dependencies.spec.ts | 15 ++++++++++ .../tool-workspace-dependencies/tsconfig.json | 3 ++ pnpm-lock.yaml | 10 +++---- scripts/gen-third-party-notices.spec.ts | 30 +++++++++---------- scripts/gen-third-party-notices.ts | 30 +++++++++---------- scripts/primary-runtime/prepare.spec.ts | 13 ++++++++ .../tool-schemas.expected.json | 2 +- 33 files changed, 122 insertions(+), 78 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml index 303cd65bd0..559bae0972 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md -2026-09-17-shared-office-runtime.md: 28daa6d3809705098722c41847fab9e3ee255bfe -2026-09-17-shared-office-runtime.zh.md: 09cc54e254546aa2787c37ab695c922ee86f0d66 +2026-09-17-shared-office-runtime.md: 5925974ce96a91afcb97f89af58f87a3b2d8ff93 +2026-09-17-shared-office-runtime.zh.md: 61be67508f285cb3e06305c95eed617e0d5f4218 diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md index 28daa6d380..5925974ce9 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md @@ -14,6 +14,8 @@ SDK deployments need the same Office authoring libraries as Desktop while keepin The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection. +The [Desktop primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own Desktop installation and platform signing. This decision replaces only its Desktop-only builder and query placement; both records remain active. + ## Alternatives considered **Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query. diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md index 09cc54e254..61be67508f 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md @@ -14,6 +14,8 @@ SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payl [共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。 +[Desktop 第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)继续持有 Desktop 安装与平台签名规则。本决策仅替代其中由 Desktop 独占构建器和查询实现的部分;两份记录均保持有效。 + ## 考虑过的替代方案 **将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 50233385bf..8dacaafa96 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 -2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 +2026-09-14-desktop-primary-runtime.md: de535fd7125c08bf788ddf7680224c6e0df81a4a +2026-09-14-desktop-primary-runtime.zh.md: 508c574e4970b4cc53ef3bdaee8524a867673d34 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index 51bbaac89c..de535fd712 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -14,7 +14,7 @@ Desktop ships Python, Node.js, pnpm, data-processing libraries and Office author The application version, component versions, Python distribution versions and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected, and present numpy/pandas distribution versions must agree with their component versions. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests. -Node downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. +The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. @@ -22,6 +22,8 @@ Windows signed packaging separates materialization from execution with supervise Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. +The [shared-runtime decision](../architecture/2026-09-17-shared-office-runtime.md) owns builder, lock and query-package placement for Desktop and SDK carriers, including GNU/Linux x64 and Python-only payloads. Desktop retains the installation and signing behavior described here and requires the full Python, Node.js and pnpm payload. + ## Alternatives considered **System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 4f27d61b5e..508c574e49 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -14,7 +14,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为 应用版本、组件版本、Python 分发包版本和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝;清单包含 numpy/pandas 分发包版本时,必须与相应组件版本一致。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。 -Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 +共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 @@ -22,6 +22,8 @@ Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 +[共享运行时决策](../architecture/2026-09-17-shared-office-runtime.zh.md)持有 Desktop 与 SDK 载体的构建器、锁文件和查询包归属,覆盖 GNU/Linux x64 与 Python-only payload。Desktop 保留本文的安装与签名行为,并要求完整的 Python、Node.js 和 pnpm payload。 + ## Alternatives considered **只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 888d5b333c..c92bdd5546 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: c4458fac7de4d9e9b63b23855f95aaf69eb89f64 -2026-07-30-generated-third-party-notices.zh.md: f26d0d001ed06dd5bea00bbc5e3400b31b254081 +2026-07-30-generated-third-party-notices.md: 71f1fd0a5b650d53c5ef21438e17e0a6e162bdf5 +2026-07-30-generated-third-party-notices.zh.md: e3adb43e4b407945a8cca88105de689407b5908b diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index c4458fac7d..71f1fd0a5b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -12,13 +12,13 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o ## Decision -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. +**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. -The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the Desktop runtime lock's `pythonPackages`. Desktop entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The Desktop wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Desktop distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. +The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. **Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index f26d0d001e..e3adb43e4b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 +**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 -文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及 Desktop 运行时锁文件 `pythonPackages` 中的每个分发包。Desktop 条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。Desktop wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。Desktop 分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 +文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 **分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b2d8c6da7c..5295b36d89 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -244,9 +244,9 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | -## Desktop bundled Python distributions +## Bundled Python distributions -The [Desktop runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f4b0246e8e..6d5d0707d4 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 123e27d1d03bf218835af310cb529365cdc47b94 -README.zh.md: aee40ffa4ceae673a931f076d668debfdf83de2d +README.md: 91e093e17fe7b748beda76a39c2002732b892902 +README.zh.md: 484788eaaa5c7ce604f784c0f8b89688ecc5c815 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 123e27d1d0..91e093e17f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -28,7 +28,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index aee40ffa4c..484788eaaa 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -28,7 +28,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c060f1c673..aed9d6f839 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -50,10 +50,8 @@ "electron": "^44.0.0", "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", - "fflate": "^0.8.2", "js-yaml": "^4.2.0", "pnpm": "11.7.0", - "tar": "^7.5.0", "typescript": "^6.0.3" } } diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 27d34f7af2..6942c71a25 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 64ef9c99d2cc98690097d848ab7cfb899c6bb474 -config-catalog.zh.md: 76faa711e53011ba34d599f59f616280a4660ad0 +config-catalog.md: 2efbd1bda5d4445fe53c1dd8cc1ad27cd3cfcead +config-catalog.zh.md: bb95afa5cb2b7825e45d9ec5315ea1a3ab79de12 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64ef9c99d2..2efbd1bda5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3482,7 +3482,7 @@ export interface Config { } ``` -Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 76faa711e5..bb95afa5cb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3484,7 +3484,7 @@ export interface Config { } ``` -来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:14`](../packages/skill/tool-workspace-dependencies/src/index.ts) +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 95707a37cf..ada24e35aa 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: bdc3a5af4b1bf723879c423fe201576ca3772e64 -tool-catalog.zh.md: bc445ea8d6b0a5778e4bd191693ff01bd6781d73 +tool-catalog.md: b3854acff78dc3478bd64c87c2b2c8a89e3cdb15 +tool-catalog.zh.md: 1cf65d6e71012260347416e864117c78c528e890 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index bdc3a5af4b..b3854acff7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -2445,7 +2445,7 @@ Source: [`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/to ### `load_workspace_dependencies` -Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings. +Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index bc445ea8d6..1cf65d6e71 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -2453,7 +2453,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 ### `load_workspace_dependencies` -获取随包附带的 Python、Node.js、pnpm 和库目录的绝对路径,以及随包 Python 发行版的版本。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。用返回的 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 +获取随包附带的 Python 和库目录的绝对路径,以及随包 Python 发行版的版本。payload 提供 Node.js 和 pnpm 时才返回对应路径。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。返回 Node.js 和 pnpm 路径时,用该 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 ```json { diff --git a/package.json b/package.json index 16e6180821..5315c1a8d9 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", "check:node-compat": "tsx scripts/run-gates.ts node-compat", - "pnpm": "11.7.0", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "npm run build:lib:host && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", diff --git a/packages/skill/tool-workspace-dependencies/README.i18n.yaml b/packages/skill/tool-workspace-dependencies/README.i18n.yaml index 9664f745e2..19c7ea6952 100644 --- a/packages/skill/tool-workspace-dependencies/README.i18n.yaml +++ b/packages/skill/tool-workspace-dependencies/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/skill/tool-workspace-dependencies/README.md -README.md: 1464e231980bc24559d5dfc2635d51e57bcfc116 -README.zh.md: 48520369dde7cb0a5fcfb8d53af687ce651c47d5 +README.md: 77020b8ca21224b9c2aeed440c7837a1fc610e7c +README.zh.md: 62ca9c088a3c6b69343285699864b2cd3418d683 diff --git a/packages/skill/tool-workspace-dependencies/README.md b/packages/skill/tool-workspace-dependencies/README.md index 1464e23198..77020b8ca2 100644 --- a/packages/skill/tool-workspace-dependencies/README.md +++ b/packages/skill/tool-workspace-dependencies/README.md @@ -25,7 +25,7 @@ Deployments that ship their own script runtimes (Desktop's primary runtime, or a ## Use this package -Mount the plugin beside the tool registry with the payload directory. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. +Mount the plugin beside the tool registry with the payload directory. Configuration validation requires a nonempty `source` and rejects empty `root` values before activation; both paths must be absolute. The bundled Office skills (`@deepseek-ai/dsh-skill-office`) reference this tool by name for their default interpreter. ### Minimal configuration diff --git a/packages/skill/tool-workspace-dependencies/README.zh.md b/packages/skill/tool-workspace-dependencies/README.zh.md index 48520369dd..62ca9c088a 100644 --- a/packages/skill/tool-workspace-dependencies/README.zh.md +++ b/packages/skill/tool-workspace-dependencies/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与工具注册表一起挂载,给出 payload 目录。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 +与工具注册表一起挂载,给出 payload 目录。配置校验在激活前要求非空 `source` 并拒绝空 `root`;两条路径都必须为绝对路径。内置 Office skills(`@deepseek-ai/dsh-skill-office`)按名字引用本工具取默认解释器。 ### 最小配置 diff --git a/packages/skill/tool-workspace-dependencies/package.json b/packages/skill/tool-workspace-dependencies/package.json index b3f26fcf86..81597a74dc 100644 --- a/packages/skill/tool-workspace-dependencies/package.json +++ b/packages/skill/tool-workspace-dependencies/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workspace-dependencies", "description": "The load_workspace_dependencies tool: absolute paths into a bundled Python, Node.js, and pnpm payload", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -37,5 +37,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/skill/tool-workspace-dependencies/src/index.ts b/packages/skill/tool-workspace-dependencies/src/index.ts index e957ceab7d..de056b144b 100644 --- a/packages/skill/tool-workspace-dependencies/src/index.ts +++ b/packages/skill/tool-workspace-dependencies/src/index.ts @@ -3,6 +3,7 @@ import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' import { dirname, isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' /** Cordis plugin identity. */ @@ -22,6 +23,12 @@ export interface Config { readonly root?: string } +/** Require a named payload source before the Loader activates the tool. */ +export const Config: z = z.object({ + source: z.string().min(1).required(), + root: z.string().min(1), +}) + /** Versions recorded by the payload build, independent of user-installed packages. */ export interface PrimaryRuntimeManifest { readonly desktopVersion: string @@ -160,9 +167,9 @@ export async function installPrimaryRuntime(source: string, root: string): Promi const manifest = await compatibleManifest(source) await mkdir(dirname(root), { recursive: true }) const previous = `${root}.previous` - await exists(previous) - await exists(root) - if (!await exists(root) && await exists(previous)) await rename(previous, root) + const previousExists = await exists(previous) + const rootExists = await exists(root) + if (!rootExists && previousExists) await rename(previous, root) if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) { const paths = workspaceDependencyPaths(root, manifest) for (const path of payloadEntries(paths)) await stat(path) @@ -203,7 +210,7 @@ export function apply(ctx: Context, config: Config): void { }) ctx.tools.register(defineTool({ name: 'load_workspace_dependencies', - description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', + description: 'Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings.', parameters: {}, output: { schema: { diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index 73f12ab47b..f71adaee68 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -345,3 +345,18 @@ it('disposes an unused tool without preparing the payload', async () => { it.each([{ source: 'relative' }, { source: process.cwd(), root: 'relative' }])('rejects relative runtime configuration at plugin load: %j', async (config) => { await expect(tool(config)).rejects.toThrow('must be absolute paths') }) + +it.each([{}, { source: '' }, { source: 1 }])('rejects invalid payload configuration with a named source error: %j', (config) => { + expect(() => workspaceDependencies.Config(config as unknown as workspaceDependencies.Config)).toThrow('source') +}) + +it('validates the previous directory even when a current installation exists', async () => { + const { source, root, directory } = await fixture() + await installPrimaryRuntime(source, root) + const outside = join(directory, 'outside-previous') + await mkdir(outside) + await writeFile(join(outside, 'keep'), 'untouched') + await symlink(outside, `${root}.previous`, process.platform === 'win32' ? 'junction' : 'dir') + await expect(installPrimaryRuntime(source, root)).rejects.toThrow('filesystem link') + expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched') +}) diff --git a/packages/skill/tool-workspace-dependencies/tsconfig.json b/packages/skill/tool-workspace-dependencies/tsconfig.json index 28eabaa205..a3e6344b9d 100644 --- a/packages/skill/tool-workspace-dependencies/tsconfig.json +++ b/packages/skill/tool-workspace-dependencies/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../core/tools" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1c3404061..686ed5f48d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,18 +593,12 @@ importers: extract-zip: specifier: ^2.0.1 version: 2.0.1(supports-color@9.4.0) - fflate: - specifier: ^0.8.2 - version: 0.8.3 js-yaml: specifier: ^4.2.0 version: 4.3.1 pnpm: specifier: 11.7.0 version: 11.7.0 - tar: - specifier: ^7.5.0 - version: 7.5.22 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -10375,6 +10369,10 @@ importers: version: link:../../core/tools packages/skill/tool-workspace-dependencies: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index e4a452d712..8527a0d588 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -2,13 +2,13 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } +import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { CLAUDE_AGENT_SDK_PACKAGE, assertRuntimeLicenses, claudeDistributionFromManifest, collectPythonDependencies, - collectDesktopPythonDependencies, + collectBundledPythonDependencies, isOwnerAuthorizedRuntime, isPermissive, type Manifest, @@ -33,7 +33,7 @@ describe('THIRD_PARTY_NOTICES.md', () => { }, async () => { const generated = await render() expect(generated).toContain('It depends on the third-party software listed below.') - expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${desktopRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`) + expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`) expect(generated).toContain('## LibreOffice conversion kit') expect(generated).toContain('Recipients must have access to those corresponding sources and notices.') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) @@ -323,22 +323,22 @@ describe('collectPythonDependencies', () => { }) }) -describe('collectDesktopPythonDependencies', () => { - it('discloses the committed Desktop closure with its exact locked versions', () => { - const dependencies = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages) - expect(dependencies).toHaveLength(Object.keys(desktopRuntimeLock.pythonPackages).length) +describe('collectBundledPythonDependencies', () => { + it('discloses the committed bundled Python closure with its exact locked versions', () => { + const dependencies = collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages) + expect(dependencies).toHaveLength(Object.keys(primaryRuntimeLock.pythonPackages).length) expect(dependencies).toContainEqual({ - name: 'pillow', version: desktopRuntimeLock.pythonPackages.Pillow, + name: 'pillow', version: primaryRuntimeLock.pythonPackages.Pillow, license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow', }) expect(dependencies).toContainEqual({ - name: 'typing-extensions', version: desktopRuntimeLock.pythonPackages.typing_extensions, + name: 'typing-extensions', version: primaryRuntimeLock.pythonPackages.typing_extensions, license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions', }) }) it('normalizes names while preserving pinned version strings', () => { - expect(collectDesktopPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' })) + expect(collectBundledPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' })) .toEqual([ { name: 'pillow', version: '12.3.0', license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow' }, { name: 'typing-extensions', version: '4.16.0', license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions' }, @@ -346,18 +346,18 @@ describe('collectDesktopPythonDependencies', () => { }) it('rejects duplicate normalized distribution names with the same locked version', () => { - expect(() => collectDesktopPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' })) + expect(() => collectBundledPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' })) .toThrow('duplicate normalized names') }) it('rejects missing distribution metadata and conflicting normalized versions', () => { - expect(() => collectDesktopPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA') - expect(() => collectDesktopPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions') + expect(() => collectBundledPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA') + expect(() => collectBundledPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions') }) it('applies the runtime license check to bundled Python distributions', () => { - expect(() => { assertRuntimeLicenses(collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)) }).not.toThrow() - const dependencies = collectDesktopPythonDependencies({ 'copyleft-wheel': '1.0' }, { + expect(() => { assertRuntimeLicenses(collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages)) }).not.toThrow() + const dependencies = collectBundledPythonDependencies({ 'copyleft-wheel': '1.0' }, { 'copyleft-wheel': { license: 'GPL-3.0-only', repo: 'https://example.com/project' }, }) expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('copyleft-wheel (GPL-3.0-only)') diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c65dbdc6f1..6167190367 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -1,7 +1,7 @@ /** * Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every * external dependency named by a workspace `package.json`, the vendored-package - * manifest in `vendor/README.md`, the Python `pyproject.toml` files, the Desktop + * manifest in `vendor/README.md`, the Python `pyproject.toml` files, the shared * Python distribution lock, and the pnpm patch list. npm metadata comes from the installed * store, so the tree must be installed. `--check` verifies the committed * artifact. Tier policy and ownership live in @@ -13,7 +13,7 @@ import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' -import desktopRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } +import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' } import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') @@ -93,7 +93,7 @@ const OVERRIDES: Record = { /** * Python metadata is recorded from the distributions' license and project * fields; generation does not require installing their wheels. Both Python - * manifests and the Desktop lock reject names absent from this map. + * manifests and the shared runtime lock reject names absent from this map. */ const PYTHON_METADATA: Record = { pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' }, @@ -616,12 +616,12 @@ function collectPython(): { name: string; license: string; repo: string; role: s } /** - * Disclose every Desktop wheel distribution using its locked version, rejecting duplicate normalized names. - * @param packages - Distribution names and exact versions from the Desktop runtime lock. + * Disclose every bundled wheel distribution using its locked version, rejecting duplicate normalized names. + * @param packages - Distribution names and exact versions from the shared runtime lock. * @param metadata - License and source metadata for every locked distribution. * @returns Normalized, sorted distribution identities with versions and licenses. */ -export function collectDesktopPythonDependencies( +export function collectBundledPythonDependencies( packages: Readonly>, metadata: PythonMetadata = PYTHON_METADATA, ): { name: string; version: string; license: string; repo: string }[] { @@ -630,14 +630,14 @@ export function collectDesktopPythonDependencies( const name = normalizePythonDistributionName(distribution) const previous = normalized.get(name) if (previous !== undefined && previous !== version) { - throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has conflicting locked versions.`) + throw new Error(`gen-third-party-notices: bundled Python distribution ${name} has conflicting locked versions.`) } - if (previous !== undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has duplicate normalized names.`) + if (previous !== undefined) throw new Error(`gen-third-party-notices: bundled Python distribution ${name} has duplicate normalized names.`) normalized.set(name, version) } return [...normalized].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => { const entry = metadata[name] - if (entry === undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} is missing from PYTHON_METADATA.`) + if (entry === undefined) throw new Error(`gen-third-party-notices: bundled Python distribution ${name} is missing from PYTHON_METADATA.`) return { name, version, license: entry.license, repo: entry.repo } }) } @@ -753,7 +753,7 @@ export async function render(): Promise { const kitRuntime = runtimeDeps.some(dep => dep.name === LIBREOFFICE_KIT_PACKAGE) const vendored = collectVendored() const python = collectPython() - const desktopPython = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages) + const bundledPython = collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages) const patched = collectPatched() const claudeDistribution = runtimeDeps.some( dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE, @@ -762,7 +762,7 @@ export async function render(): Promise { : undefined const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) assertRuntimeLicenses(runtimeDeps) - assertRuntimeLicenses(desktopPython) + assertRuntimeLicenses(bundledPython) const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`) return ` @@ -574,15 +574,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise /** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. + * Read the parent's catalog and discover missing direct children from Session headers. * The service owns and releases the live-preferred Session observation. * @param parentSessionId - parent whose direct children are requested. * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. + * @returns catalog children in event order, then unresolved children whose bodies remain unread. * @throws {@link SubagentError} when query or catalog projection is unavailable. * @throws SessionQueryError when the parent cannot be read or the query is cancelled. */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** * Enumerate the root's complete session-backed subagent tree in stable diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index ff153c7861..03ada2686d 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -268,7 +268,7 @@ interface ContinuableCreateSpec { 模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。 -`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 保留语料库遍历、子级身份诊断和完整的 `hasChildren` 计算。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 +`SubagentRuntime.listChildren(parentSessionId, signal?)` 持有一次父观察,并以会话头部发现补充 catalog。它返回 `SubagentDiscoveryEntry[]`:先按事件顺序返回 catalog 事实,再为缺少 catalog 事实的子会话返回 `{ id: SessionId; createdAt: number; mode: 'unresolved' }` 行。不打开任何子正文。查询失败向上传播,缺少父 catalog 投影时显式报错。`listDescendants()` 显式读取子身份,返回诊断和完整 `hasChildren` 信息。[会话独立迁移](../../.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md) 负责按需发现与迁移隔离。 `SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期: @@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 - **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 -`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 +`SubagentCatalogEntry` 描述持久化成功创建事实,`SubagentCatalogState` 是其 Host 投影状态。`SubagentDiscoveryEntry` 为直接列表增加非持久化、未解析的头部行。浏览器消费者合并 catalog 事实、关联父会话的摘要与缓存子身份。`SubagentAddress` 携带父子 id 及已知或未解析模式;历史打开在续接前校验子会话 descriptor。`SubagentCatalogRow` 属于完整后代列表。 @@ -578,15 +578,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise /** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. + * Read the parent's catalog and discover missing direct children from Session headers. * The service owns and releases the live-preferred Session observation. * @param parentSessionId - parent whose direct children are requested. * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. + * @returns catalog children in event order, then unresolved children whose bodies remain unread. * @throws {@link SubagentError} when query or catalog projection is unavailable. * @throws SessionQueryError when the parent cannot be read or the query is cancelled. */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise +listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** * Enumerate the root's complete session-backed subagent tree in stable diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 04a2230cee..dff680a206 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 2f5746fc63e73b1caf2692a05ff942c13399e6bc -tool-catalog.zh.md: 6fb80b689ca569834e8f585c765547d23cec50df +tool-catalog.md: 03e2813a528ed1415f9e89593ce4dc8fa884b7e5 +tool-catalog.zh.md: d66c7677a0452fac8557596cd2c666136c0630b1 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2f5746fc63..03e2813a52 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1886,7 +1886,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. +List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be identified without opening them are reported as diagnostics. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 6fb80b689c..d66c7677a0 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1892,7 +1892,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `list_agents` -按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 +按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法在未打开时确定身份的子级会作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。 ```json { diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8ceb4a8da4..c2406e4699 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 228ed88ed20b9f9581496468e47079afd1542e4f -README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a +README.md: 01701383352458de80d82bbfa1183136fb0bdade +README.zh.md: 4ac801506db13faceee1f8c7971f90a6aae0bfff diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 228ed88ed2..0170138335 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,7 +29,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. Header-discovered subagent history accepts `mode: 'unresolved'`; opening still validates the exact parent and the child’s own descriptor. The Client derives the usable mode from the opened child projection, and a failed child open does not invalidate its parent. Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index edebf8bdda..4ac801506d 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 从头部发现的子代理历史接受 `mode: 'unresolved'`;打开时仍校验精确父会话和子会话自身 descriptor。Client 从已打开子会话的投影确定可用模式,子会话打开失败不会使父会话失效。 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index b6061ce2b4..f396b364fd 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -165,6 +165,14 @@ export class SessionManager { } } } + const summary = this.summaries.find(item => item.sessionId === sessionId) + if (summary?.origin === 'subagent' && summary.parentSessionId !== undefined) { + return { + parentSessionId: summary.parentSessionId, + childSessionId: sessionId, + mode: this.projectionStores.get(sessionId)?.values().subagent?.mode ?? 'unresolved', + } + } return undefined } @@ -666,10 +674,6 @@ export class SessionManager { this.listInflight = null void this.refreshList() const parents = new Set(this.projectionLoads.keys()) - for (const id of this.sessions.keys()) { - const address = this.addresses.get(id) - if (address !== undefined) parents.add(address.parentSessionId) - } for (const { controller } of this.projectionInflight.values()) controller.abort() this.projectionInflight.clear() this.projectionLoads.clear() diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index cd38a26fcf..bb4540f94c 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -812,6 +812,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -819,7 +820,9 @@ export class Session implements SessionFace { subagent: this.address === undefined ? null : { - address: this.address, + address: this.address.mode === 'unresolved' && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index a22badc1f1..ab31da6737 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -364,7 +364,7 @@ function validateAddress( reason: 'unsupported', }) } - if (identity.mode !== address.mode) { + if (address.mode !== 'unresolved' && identity.mode !== address.mode) { throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a925e9da83..bd4d3d1840 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -393,7 +393,7 @@ export type SessionAddress = readonly kind: 'subagent' readonly parentSessionId: SessionId readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' + readonly mode: 'one-shot' | 'continuable' | 'unresolved' } /** One non-activating Session projection read. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 7ee5be5397..05495adfee 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -417,6 +417,33 @@ describe('Host Remote event routing', () => { }) describe('subagent catalogs', () => { + it.for(['one-shot', 'continuable'] as const)('opens a header-discovered child on demand and resolves %s mode', async (mode, { mock, remote, start }) => { + remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [ + summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), + ] }))) + mock.load(sessionWorld) + mock.stream(FOLLOW, followScript(ok({ + records: [], hasMore: false, + projections: { asOfSeq: 0, values: { subagent: { mode, label: 'restored', seq: 0 } } }, + }))) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + try { + await manager.refreshList() + manager.resolveTarget(S2) + expect(manager.subagentAddress(S2)).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unresolved' }) + expect(remote.session.projections).not.toHaveBeenCalled() + expect(mock.log.requests(FOLLOW)).toEqual([]) + const child = manager.get(S2) + await child.open() + expect(mock.log.requests(FOLLOW)).toEqual([{ + address: { kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'unresolved' }, + assistantStream: true, maxMessages: 50, + }]) + expect(child.getSnapshot().subagent?.address.mode).toBe(mode) + } finally { await manager.dispose() } + }) + it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => { remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [ summary(S1), @@ -1010,33 +1037,24 @@ describe('connected generation', () => { expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore) }) - it('retains the durable parent address and refreshes that parent across reconnect', async ({ mock, remote }) => { + it('retains the durable parent address across reconnect without opening the parent', async ({ mock, remote }) => { const address = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, } remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [summary(S1)] as never[] }))) - const parent = Promise.withResolvers>>() - remote.session.projections.mockImplementation(() => parent.promise) const manager = makeManager(mock, remote) manager.resolveTarget(address) manager.get(S2) manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) - parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, - id: S2, mode: 'continuable', label: 'worker', - }] } })) await vi.waitFor(() => { expect(remote.session.list.mock.calls.map(([request]) => request)).toHaveLength(1) }) - await vi.waitFor(() => { - expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([ - { sessionId: S1 }, - ]) - }) + expect(remote.session.projections).not.toHaveBeenCalled() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, parentAvailable: true, diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 04b43972e9..2fae510838 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -641,6 +641,27 @@ describe('SessionHistoryController', () => { .rejects.toMatchObject({ code: 'subagent/unauthorized' }) }) + it('resolves a header-discovered address while retaining child ownership and descriptor validation', async () => { + const bench = await setup() + const parentSessionId = SessionId('unresolved-parent') + const childSessionId = SessionId('unresolved-child') + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, id: childSessionId, createdAt: 1, cwd: '/workspace', + isSeeded: false, origin: 'subagent', parentSession: parentSessionId, + } + cold(bench.ctx, header, [event('subagent/descriptor', SessionSeq(0), { + version: 3, mode: 'one-shot', provider: 'spawn', + })]) + const address = { kind: 'subagent', parentSessionId, childSessionId, mode: 'unresolved' } as const + await expect(bench.transport.page({ address, throughSeq: 0 }, signal())).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(bench.transport.page({ address: { ...address, parentSessionId: SessionId('wrong') }, throughSeq: 0 }, signal())) + .rejects.toMatchObject({ code: 'subagent/unauthorized' }) + await expect(bench.transport.page({ address: { ...address, mode: 'continuable' }, throughSeq: 0 }, signal())) + .rejects.toMatchObject({ code: 'subagent/unauthorized' }) + }) + it('reports an unavailable descriptor when an observed child has no projection value', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 45a496719c..83ca816b51 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: 1404e893add386b88a24d78675a4f0be06f497a0 -README.zh.md: 97c743b580e25cd50bcb01a3a7547cb7415a0e08 +README.md: 33d8da78b2cf27ccce1cfa8ee7c26ddb43b3d083 +README.zh.md: 9cc1fff725d8539abc648ab7d198413194674160 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 1404e893ad..33d8da78b2 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -25,17 +25,17 @@ Use this package to browse every subagent conversation beneath a parent session, ## Use this package -The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. +The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger for known direct children or a catalog error. Rows combine existing parent catalog facts with Session-list header relationships, so historical children remain discoverable without opening their logs. Opening the menu or expanding a branch refreshes the lightweight Session list. Selecting a row opens only that child through its exact `{parentSessionId, childSessionId, mode}` address; a header-only row uses `mode: 'unresolved'` until its own descriptor is read. The trailing arrow opens the same address in the right Sidebar. -This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. +This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the lightweight Session list before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. ### Browsing the tree -Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty. +Rows display mode plus activity and an optional log-backed title; unresolved rows display “Not opened” and use their id until identity data is available. Known parent catalog labels remain authoritative. Running, completed, and other inactive children use the shared ongoing, success, and idle indicators. Cached usage and duration remain optional. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree. A header-discovered child with no listed descendants or cached catalog entries is a leaf without a body read. ### Continuing a conversation -A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable. +An unresolved child keeps a read-only composer until opening its history supplies a valid identity. A continuable child with a live parent keeps the ordinary input chrome: follow-ups join the child’s FIFO inbox, and Stop routes through `subagents/interruptByParent`. A continuable child whose parent is unavailable and which is not running uses a read-only composer; while it runs, input and Send remain disabled but Stop stays usable. One-shot history remains read-only. ### The `@` reference source diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 97c743b580..9cc1fff725 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,17 +25,17 @@ kind: "package-reference" ## 使用本包 -会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 +会话头部保留当前标题作为层级导航,并为已知直接子会话或 catalog 错误追加 `/` 数量入口。列表行合并已有父 catalog 事实和会话列表中的头部关系,让历史子会话无需打开日志即可被发现。打开菜单或展开分支只刷新轻量会话列表。选择一行时,通过精确的 `{parentSessionId, childSessionId, mode}` 地址只打开该子会话;只有头部的条目使用 `mode: 'unresolved'`,直到读取自身 descriptor。末尾箭头将同一地址打开到右侧 Sidebar。 -本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 +本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源和内置 Sidebar 标签类型。资源在持有子会话 `SessionReference` 前刷新轻量会话列表,并在标签记录关闭时释放引用。标签通过 `sidebar.chat.conversation` 渲染共享的 `conversation.content` Factory,将局部 View 固定为 Chat,省略主 Conversation 头部和宽度控件。 ### 浏览目录 -行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。 +列表行显示模式、活动状态和可选的日志标题;未解析条目显示“尚未打开”,在身份数据可用前使用 id。已有父 catalog 标签仍具有权威性。运行、完成和其他空闲子会话分别使用共享的进行中、成功和空闲标记。缓存的用量和耗时为可选项。ArrowRight/ArrowLeft 展开或收起分支,ArrowUp/ArrowDown、Home、End、Escape 用于导航或关闭树。头部发现的子会话若没有列出的后代或缓存 catalog 条目,无需读取正文即可作为叶节点。 ### 续接对话 -确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。 +未解析子会话保持只读输入区,直到打开历史记录并得到有效身份。父代理在线的可继续子会话使用普通输入区:后续消息进入子代理 FIFO 收件箱,Stop 经 `subagents/interruptByParent` 路由。父代理不可用且未运行的可继续子会话使用只读输入区;仍运行时禁用输入和 Send,但保留 Stop。一次性子会话历史始终只读。 ### `@` 引用 source diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 6dd416fb95..e734fd9dd9 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -17,7 +17,7 @@ import css from './SubagentHeaderLineage.module.css' type SubagentCatalogSnapshot = Omit & { state: 'loading' | 'ready' | 'error' - entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[] + entries: { id: SessionId; mode: SubagentAddress['mode']; label?: string; activity: 'running' | 'inactive' }[] } type Catalogs = Readonly> @@ -238,7 +238,8 @@ function CatalogRows({ || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] const label = entry.label ?? entry.id - const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') + const mode = entry.mode === 'unresolved' ? t('mode.unresolved') + : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') const completed = entry.activity === 'inactive' && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true const activity = entry.activity === 'running' @@ -451,15 +452,31 @@ function CatalogDropdown({ const projections = useSessions(state => state.projectionsBySession) const summaries = useSessions(state => state.byId) const statuses = useSessionStatus(value => value) - const catalogs = useMemo(() => Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, { - state: snapshot.state === 'idle' - ? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready' - : snapshot.state, - error: snapshot.error, - entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({ - ...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const, - })), - }])), [projections, summaries, statuses]) + const catalogs = useMemo(() => { + const result: Record = Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, { + state: snapshot.state === 'idle' + ? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready' + : snapshot.state, + error: snapshot.error, + entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({ + ...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const, + })), + }])) + const known = new Map(Object.entries(result).map(([id, catalog]) => [id, new Set(catalog.entries.map(entry => entry.id))])) + for (const summary of Object.values(summaries)) { + if (summary.origin !== 'subagent' || summary.parentId === undefined) continue + result[summary.id] ??= { state: 'ready', error: null, entries: [] } + const catalog = result[summary.parentId] ??= { state: 'ready', error: null, entries: [] } + if (known.get(summary.parentId)?.has(summary.id)) continue + const identity = summary.projectionValues?.subagent + catalog.entries.push({ + id: summary.id, mode: identity?.mode ?? 'unresolved', + ...(identity?.label === undefined ? {} : { label: identity.label }), + activity: (statuses.get(summary.id)?.running ?? summary.running) ? 'running' : 'inactive', + }) + } + return result + }, [projections, summaries, statuses]) const catalog = catalogs[rootSessionId] const [open, setOpen] = useState(false) const [menuPosition, setMenuPosition] = useState() @@ -473,7 +490,7 @@ function CatalogDropdown({ ? undefined : catalog?.entries.find(entry => entry.id === currentSessionId) const switcherDisplayTitle = currentEntry !== undefined - ? currentEntry.label ?? currentEntry.id + ? currentEntry.label ?? (currentEntry.mode === 'unresolved' ? displayTitle : currentEntry.id) : displayTitle const directChildren = catalog?.entries ?? [] const directCount = directChildren.length diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 158e8cb77b..1271d13f2c 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ export interface SubagentReadOnlyMatch { - reason: 'one-shot' | 'parent-unavailable' + reason: 'one-shot' | 'parent-unavailable' | 'unresolved' } /** Full chain props after the read-only subagent selector accepts the owner currency. */ @@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(matched.reason === 'unresolved' ? 'readonly.unresolved.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 0055ea8c3f..ce92feca36 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -37,6 +37,7 @@ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRig function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null + if (subagent.address.mode === 'unresolved') return { reason: 'unresolved' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } // Until a Host summary establishes parent availability, keep the normal // disabled composer instead of claiming that the parent is offline. @@ -67,8 +68,8 @@ export function apply(ctx: ClientContext): void { preferNewPane: true, }) }, - refresh(parentSessionId: SessionId) { - void sessions.refreshProjections(parentSessionId) + refresh() { + void sessions.refresh() }, }) ctx.slots.inject( diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index cc966d411c..2de85e949c 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -22,6 +22,7 @@ export const zh = { 'loading.label': '正在加载子代理…', 'load.error': '无法加载子代理', 'retry': '重试', + 'mode.unresolved': '尚未打开', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', 'activity.running': '正在运行', @@ -40,6 +41,7 @@ export const zh = { 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', + 'readonly.unresolved.body': '打开会话后才能确认是否支持后续消息。', 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', } as const @@ -62,6 +64,7 @@ export const en: Record = { 'loading.label': 'Loading subagents…', 'load.error': 'Unable to load subagents', 'retry': 'Retry', + 'mode.unresolved': 'Not opened', 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', 'activity.running': 'running', @@ -80,6 +83,7 @@ export const en: Record = { 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', + 'readonly.unresolved.body': 'Open this conversation to check whether it supports follow-up messages.', 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', } diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index a4fe489714..f83b9bb07f 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -73,7 +73,7 @@ export function parseSubagentChatAddress(value: string): SubagentAddress | undef if (parts.length !== 2 || parts[0] !== 'session') return undefined const parentSessionId = url.searchParams.get('parent') const mode = url.searchParams.get('mode') - if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) { + if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable' && mode !== 'unresolved')) { return undefined } try { @@ -106,7 +106,7 @@ function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<'su const address = parseSubagentChatAddress(resourceAddress) if (address === undefined) throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`) if (isAbortRequested(signal)) return - await sessions.refreshProjections(address.parentSessionId) + await sessions.refresh() if (isAbortRequested(signal)) return const reference = sessions.retain(address, { source: 'sidebarChat', signal }) try { diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 49b2ac6016..ab7dc11320 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { SessionListState, SessionSnapshot, SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -51,8 +51,8 @@ function sessionsWith(sessions: SessionSummary[]) { subagentAddress: (childSessionId: SessionId) => childSessionId === address.childSessionId ? address : undefined, - refreshProjections: (parentSessionId: SessionId) => { - actionCalls.push({ method: 'refreshProjections', args: [parentSessionId] }) + refresh: () => { + actionCalls.push({ method: 'refresh', args: [] }) return Promise.resolve() }, } @@ -74,6 +74,10 @@ async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() const face = sessionsWith(sessions) ctx.provide('sessions', face) + const registerResources = vi.fn(() => () => {}) + const registerTab = vi.fn(() => () => {}) + ctx.provide('resources', { register: registerResources }) + ctx.provide('sidebarRightTabs', { register: registerTab }) ctx.provide('uiWorkspace', { openSession: (address: SubagentAddress) => { face.actionCalls.push({ method: 'openSession', args: [address] }) @@ -89,7 +93,7 @@ async function fullBench(sessions: SessionSummary[]) { await provideSlotFaces(ctx) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() - return { face, ctx } + return { face, ctx, registerResources, registerTab } } const FAMILY: SessionSummary[] = [ @@ -108,7 +112,9 @@ describe('apply', () => { }) it('registers catalog actions and selects read-only subagent composers from session facts', async () => { - const { ctx, face } = await fullBench(FAMILY) + const { ctx, face, registerResources, registerTab } = await fullBench(FAMILY) + expect(registerResources).toHaveBeenCalledOnce() + expect(registerTab).toHaveBeenCalledOnce() const catalogEntry = ctx.slots.entries('conversation.session.header.lineage') .find(entry => entry.component === SubagentHeaderLineage)! const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent')) @@ -129,7 +135,7 @@ describe('apply', () => { { kind: 'subagentchat', preferNewPane: true }, ], }, - { method: 'refreshProjections', args: [sid('parent')] }, + { method: 'refresh', args: [] }, ]) const composerEntry = ctx.slots.entries('conversation.composer') @@ -146,6 +152,8 @@ describe('apply', () => { pendingInteraction: undefined, }) expect(select(owner(undefined))).toBeNull() + expect(select(owner({ address: { ...address, mode: 'unresolved' }, parentAvailable: true }))) + .toEqual({ reason: 'unresolved' }) expect(select(owner(null))).toBeNull() expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }))) .toEqual({ reason: 'one-shot' }) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 626026fe54..a7dc9f63a5 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -182,7 +182,7 @@ describe('SubagentHeaderLineage', () => { expect(inactiveTrigger.querySelector('[data-state="ongoing"]')).toBeNull() }) - it('does not count Sessions outside projected membership', () => { + it('adds header-discovered children without counting ordinary forks or their descendants', () => { const fork = 'fork' as SessionId const forkChild = 'fork-child' as SessionId render( { [forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true }, })} />) - const trigger = screen.getByRole('button', { name: '1 个子代理' }) + const trigger = screen.getByRole('button', { name: '2 个子代理' }) expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull() }) @@ -571,7 +571,30 @@ describe('SubagentHeaderLineage', () => { }) }) - it('shows one generic state while a child catalog loads', () => { + it('shows a loading branch until its child headers arrive', () => { + const input = props(catalog(), { [CHILD]: catalog({ entries: [], state: 'loading' }) }) + render() + hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + expect(screen.getByText('正在加载子代理…')).toBeTruthy() + }) + + it('uses an already opened child identity without loading the parent catalog', () => { + const input = props(undefined, {}, { + [CHILD]: { + ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent', + projectionValues: { subagent: { mode: 'one-shot', label: 'restored', seq: 0 } }, + }, + }) + render() + hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ })) + fireEvent.click(screen.getByRole('treeitem', { name: /restored/ })) + expect(input.openChild).toHaveBeenCalledWith({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot', + }) + }) + + it('shows header-discovered descendants before their identity projections load', () => { const secondGrandchild = 'grandchild-2' as SessionId const summaries = { [CHILD]: { ...summary(CHILD, 1), running: true }, @@ -589,15 +612,18 @@ describe('SubagentHeaderLineage', () => { fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) expect(deferred.refresh).toHaveBeenCalledWith(CHILD) - expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true') - expect(screen.getByText('正在加载子代理…')).toBeTruthy() - expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull() + expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull() + expect(screen.getAllByRole('treeitem', { name: /尚未打开/ })).toHaveLength(2) + fireEvent.click(screen.getByRole('treeitem', { name: /grandchild-2/ })) + expect(deferred.openChild).toHaveBeenCalledWith({ parentSessionId: CHILD, childSessionId: secondGrandchild, mode: 'unresolved' }) const loading = props(catalog(), { [CHILD]: catalog({ entries: [], state: 'loading' }), }, summaries) view.rerender() - expect(screen.getByText('正在加载子代理…')).toBeTruthy() + hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + expect(screen.getAllByRole('treeitem', { name: /尚未打开/ })).toHaveLength(2) const ready = props(catalog(), { [CHILD]: catalog({ @@ -680,7 +706,7 @@ describe('SubagentHeaderLineage', () => { expect(failed.refresh).toHaveBeenCalledWith(PARENT) }) - it('does not expose summary-only descendants as catalog rows', () => { + it('keeps header-discovered children available without parent catalog facts', () => { const second = 'child-2' as SessionId const summaries = { [CHILD]: { @@ -692,11 +718,11 @@ describe('SubagentHeaderLineage', () => { } const absent = props(undefined, {}, summaries) const view = render() - expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByRole('button', { name: /子代理/ })).toBeTruthy() const staleEmpty = props(catalog({ entries: [] }), {}, summaries) view.rerender() - expect(screen.queryByRole('button')).toBeNull() + expect(screen.getByRole('button', { name: /子代理/ })).toBeTruthy() }) it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => { @@ -889,6 +915,21 @@ describe('SubagentHeaderLineage', () => { expect(input.refresh).not.toHaveBeenCalled() }) + it('shows a loading switcher before the restored child header arrives', () => { + const input = { + ...props(undefined, {}, {}, { + parentSessionId: PARENT, childSessionId: CHILD, mode: 'unresolved', + }), + lineageSessionId: CHILD, + displayTitle: 'restored child', + } + render() + hoverCatalog(screen.getByRole('button', { name: '切换子代理:restored child' })) + expect(screen.getByText('正在加载子代理…')).toBeTruthy() + expect(input.refresh).toHaveBeenCalledWith(PARENT) + expect(input.openChild).not.toHaveBeenCalled() + }) + it('keeps a nested title switcher scoped to its direct-parent catalog', () => { const input = { ...props(catalog(), { @@ -921,6 +962,11 @@ describe('SubagentHeaderLineage', () => { }) describe('SubagentReadOnlyComposer', () => { + it('keeps an unresolved child read-only', () => { + render() + expect(screen.getByRole('status').textContent).toContain(zh['readonly.unresolved.body']) + }) + it('explains the exact missing-parent recovery path', () => { render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') diff --git a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx index 143a0cb595..4233188518 100644 --- a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -38,6 +38,8 @@ describe('Sidebar chat address', () => { expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS) expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' }))) .toEqual({ ...ADDRESS, mode: 'one-shot' }) + expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'unresolved' }))) + .toEqual({ ...ADDRESS, mode: 'unresolved' }) }) it.each([ @@ -64,7 +66,7 @@ describe('Sidebar chat registration', () => { [Symbol.dispose]: release, } as unknown as SessionReference const retain = vi.fn(() => reference) - const refreshProjections = vi.fn(() => Promise.resolve()) + const refresh = vi.fn(() => Promise.resolve()) const list = { getSnapshot: () => ({ ids: [], @@ -84,7 +86,7 @@ describe('Sidebar chat registration', () => { let definition: SidebarRightTabDefinition | undefined const registrations: { options: Record; component: unknown }[] = [] const ctx = { - sessions: { retain, refreshProjections, list } as unknown as ISessions, + sessions: { retain, refresh, list } as unknown as ISessions, resources: { register: (value: ResourceProvider<'subagentchat'>) => { provider = value; return () => {} } }, sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } }, slots: { @@ -114,7 +116,7 @@ describe('Sidebar chat registration', () => { const controller = new AbortController() const stream = provider!.open(subagentChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]() expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } }) - expect(refreshProjections).toHaveBeenCalledWith(PARENT) + expect(refresh).toHaveBeenCalledWith() expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal }) const completion = stream.next() await Promise.resolve() @@ -130,7 +132,7 @@ describe('Sidebar chat registration', () => { expect(release).toHaveBeenCalledTimes(2) let finishRefresh: (() => void) | undefined - refreshProjections.mockImplementationOnce(() => new Promise((resolve) => { finishRefresh = resolve })) + refresh.mockImplementationOnce(() => new Promise((resolve) => { finishRefresh = resolve })) const abortedDuringRefresh = new AbortController() const pending = provider!.open( subagentChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fdcdf0ae9c..0ae989a458 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: dc9ecee1df515f8e9b4183b6d97c29301c848652 -README.zh.md: 065f77ac442943b5683f0d492b56ab7d6668ba99 +README.md: 1f0b518644b80ea87cc701c8c656c27e36a317fc +README.zh.md: ad49fd683e04ed47aa7db0e8004cfb61b3e2a89e diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index dc9ecee1df..1f0b518644 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -65,7 +65,7 @@ The value is intentionally best effort for cold Sessions. An identity-matching u ----- -`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. +`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Restoring a subagent address opens only the selected child, without requesting its parent projections. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 065f77ac44..ad49fd683e 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -65,7 +65,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ----- -`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 +`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 恢复子代理地址时只打开选中的子会话,不请求其父投影。 新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 82796c0b9c..5f80fa2e43 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -274,7 +274,6 @@ class UiWorkspaceService extends Service implements UiWorkspace { private async restoreSelection(workspaces: WorkspaceSnapshot, sessions: SessionListState): Promise { const saved = this.selection.getSnapshot() if (saved.subagentAddress !== undefined) { - void this.sessions.refreshProjections(saved.subagentAddress.parentSessionId) this.replaceMain(saved.subagentAddress, this.lifetime.signal, 'preserve') return } diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 625784bb5b..d71291443e 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -715,7 +715,6 @@ describe('UiWorkspaceService', () => { expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(address, { source: 'mainView' }) expect(b.sessions.refreshProjections.mock.calls).toEqual([ - [address.parentSessionId], [address.childSessionId], ]) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de5ef44f14..453d087865 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2603,10 +2603,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['{SubagentError} `UNAUTHORIZED` when a resident target belongs to a different parent or the supplied parent identity is stale.'], }, { - signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', - description: 'Read the parent\'s durable direct-child catalog without loading or resuming an Agent. The service owns and releases the live-preferred Session observation.', + signature: 'listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise', + description: 'Read the parent\'s catalog and discover missing direct children from Session headers. The service owns and releases the live-preferred Session observation.', parameters: [{ name: 'parentSessionId', description: 'parent whose direct children are requested.' }, { name: 'signal', description: 'cancellation forwarded to the Session query.' }], - returns: 'catalog children in parent event order.', + returns: 'catalog children in event order, then unresolved children whose bodies remain unread.', throws: ['{@link SubagentError} when query or catalog projection is unavailable.', 'SessionQueryError when the parent cannot be read or the query is cancelled.'], }, { @@ -5737,7 +5737,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionAddress', - declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\';\n};', + declaration: 'export type SessionAddress = {\n readonly kind: \'session\';\n readonly sessionId: SessionId;\n} | {\n readonly kind: \'subagent\';\n readonly parentSessionId: SessionId;\n readonly childSessionId: SessionId;\n readonly mode: \'one-shot\' | \'continuable\' | \'unresolved\';\n};', }, { name: 'SessionAssistantStreamAttempt', @@ -6435,6 +6435,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentDescriptorData', declaration: 'export type SubagentDescriptorData = OneShotSubagentDescriptorData | ContinuableSubagentDescriptorData;', }, + { + name: 'SubagentDiscoveryEntry', + declaration: 'export type SubagentDiscoveryEntry = SubagentCatalogEntry | {\n readonly id: SessionId;\n readonly createdAt: number;\n readonly mode: \'unresolved\';\n};', + }, { name: 'SubagentInterruptAuthority', declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};', @@ -6485,7 +6489,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRuntime', - declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', + declaration: 'export class SubagentRuntime extends TypertRemoteService {\n static Config: z;\n constructor(ctx: Context, config: Config);\n resolveMaxDepth(configured?: number | \'provider-managed\'): number | undefined;\n async startContinuable(spec: ContinuableStartSpec): Promise;\n async sendMessage(sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions): Promise;\n interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void;\n async drainContinuableDescendants(parents: readonly Agent[]): Promise;\n async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise;\n listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise;\n listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise;\n @Remote(\'prompt\')\n async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise;\n @Remote(\'interruptByParent\')\n interruptByParent(childSessionId: SessionId, parentSessionId: SessionId, mode: \'continuable\'): SubagentInterruptReceipt;\n registerProvider(provider: SubagentProvider): () => void;\n getProvider(name: string): SubagentProvider | undefined;\n list(): string[];\n async start(name: string, request: SubagentStartRequest): Promise;\n}', }, { name: 'SubagentSendMessageOptions', diff --git a/packages/session/session-format-catalog/README.i18n.yaml b/packages/session/session-format-catalog/README.i18n.yaml index 31246e1ed0..d792370356 100644 --- a/packages/session/session-format-catalog/README.i18n.yaml +++ b/packages/session/session-format-catalog/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-catalog/README.md -README.md: bb86bcaffee2c2e17afffd4ce7fbd917bf1c30ee -README.zh.md: 156361ae330967169ae82e204ddeff26c50b05bd +README.md: 82c1635ced88651ddcb250bd2d6b03e021faa176 +README.zh.md: 07758cd5ab14010a16889073f4b5b046e9dd0ba7 diff --git a/packages/session/session-format-catalog/README.md b/packages/session/session-format-catalog/README.md index bb86bcaffe..82c1635ced 100644 --- a/packages/session/session-format-catalog/README.md +++ b/packages/session/session-format-catalog/README.md @@ -33,7 +33,7 @@ Import this library from persistence and test-support readers that need the comp ```text const descriptor = sessionFormatCatalog.readHeader(physicalHeader) -const catalog = createSessionFormatCatalogWithChildren(childFacts) +const catalog = sessionFormatCatalog const restore = catalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' }) for (const row of physicalRows) restore.decodeRow(row) const current = restore.finish() @@ -47,7 +47,7 @@ Production historical reads select `{ recovery: 'recoverable', validation: 'tran The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. The browser-safe `./message-projections` export assembles current plugin-owned interpreters for detached constructors and surface folds; it does not mount recovery listeners. -`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 using the fixed released-V3 event vocabulary to collect child prerequisites without recursively completing their catalogs. An ignorable V3 extension remains opaque even when the installed writer knows its name. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog. +`sessionFormatCatalog` restores historical bodies independently of other Sessions. `createSessionFormatCatalogWithChildren(childFacts)` optionally supplements parent catalogs for offline fixture preparation and Preview packing; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 using the fixed released-V3 vocabulary for that evidence collection. An ignorable V3 extension stays opaque even when the current writer knows its name. Supplied evidence remains unchanged for the catalog’s lifetime, and every restore owns independent stage state. ----- diff --git a/packages/session/session-format-catalog/README.zh.md b/packages/session/session-format-catalog/README.zh.md index 156361ae33..07758cd5ab 100644 --- a/packages/session/session-format-catalog/README.zh.md +++ b/packages/session/session-format-catalog/README.zh.md @@ -33,7 +33,7 @@ kind: "package-library" ```text const descriptor = sessionFormatCatalog.readHeader(physicalHeader) -const catalog = createSessionFormatCatalogWithChildren(childFacts) +const catalog = sessionFormatCatalog const restore = catalog.createRestore(physicalHeader, { recovery: 'recoverable', validation: 'transformed' }) for (const row of physicalRows) restore.decodeRow(row) const current = restore.finish() @@ -47,7 +47,7 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform 该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。浏览器安全的 `./message-projections` 导出为独立构造函数和 surface 折叠装配当前插件拥有的处理器,不挂载恢复监听器。 -`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 使用固定的已发布 V3 事件词汇恢复 V0–V3,收集子日志前置事实而不递归补齐其目录。即使已安装写入方认识事件名,可忽略 V3 扩展仍保持不透明。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。 +`sessionFormatCatalog` 独立于其他会话恢复历史正文。`createSessionFormatCatalogWithChildren(childFacts)` 可在离线夹具准备和 Preview 打包时补充父会话 catalog;参见 [catalog 补全规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 使用固定的已发布 V3 词汇恢复 V0–V3,为此收集证据。即使当前 writer 已认识某个名称,可忽略的 V3 扩展仍保持不透明。传入证据在 catalog 生命周期内保持不变,每次恢复拥有独立的阶段状态。 ----- diff --git a/packages/session/session-format-catalog/src/children.ts b/packages/session/session-format-catalog/src/children.ts index 580e46d2ae..bee7de134f 100644 --- a/packages/session/session-format-catalog/src/children.ts +++ b/packages/session/session-format-catalog/src/children.ts @@ -7,7 +7,7 @@ import { sessionFormatCatalogOptions } from './generated.ts' /** * Assemble a catalog whose V3→V4 edge knows one parent's historical children. - * @param children - complete child evidence retained unchanged for the catalog's lifetime; an empty array declares no children. + * @param children - complete child evidence retained unchanged for the catalog's lifetime; an empty array requests no backfill. * @returns a catalog with independent restore state per artifact and unchanged current-format readers. */ export function createSessionFormatCatalogWithChildren(children: readonly SessionFormatJsonValue[]): SessionFormatCatalog { diff --git a/packages/session/session-format-catalog/tests/children.spec.ts b/packages/session/session-format-catalog/tests/children.spec.ts index 4b2d465e93..7e600a8095 100644 --- a/packages/session/session-format-catalog/tests/children.spec.ts +++ b/packages/session/session-format-catalog/tests/children.spec.ts @@ -7,9 +7,9 @@ const child = (id: string) => ({ childId: id, childCreatedAt: 2, descriptorCount descriptor: { version: 3, provider: 'spawn', mode: 'one-shot' } }) describe('parent-specific catalog assembly', () => { - it('requires explicit children for historical bodies while retaining header and native V4 reads', () => { + it('restores historical bodies independently while retaining optional catalog completion', () => { expect(sessionFormatCatalog.readHeader(header).status).toBe('migration-required') - expect(() => sessionFormatCatalog.createRestore(header, policy)).toThrow('explicit historical child facts') + expect(sessionFormatCatalog.createRestore(header, policy).finish().events).toEqual([]) expect(createSessionFormatCatalogWithChildren([]).createRestore(header, policy).finish().events).toEqual([]) expect(sessionFormatCatalog.createRestore({ ...header, version: 4 }, policy).finish().events).toEqual([]) }) diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml index 892da2ad10..8b6ee01f8d 100644 --- a/packages/session/session-format-v3-to-v4/README.i18n.yaml +++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md -README.md: 2c2d3b4342d68cec5e9a9f94f969a380bbbf15f2 -README.zh.md: a92ae34a73f134253880fec3f931815e067bc344 +README.md: cb23641b3cc3b9a60ee1dc3429ab529809df6c93 +README.zh.md: 67148ff09cc43eed9aed523f5ae7670c3348403b diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md index 2c2d3b4342..cb23641b3c 100644 --- a/packages/session/session-format-v3-to-v4/README.md +++ b/packages/session/session-format-v3-to-v4/README.md @@ -48,7 +48,7 @@ Header-only migration validates and advances metadata without reading the body o const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader) ``` -Historical body restoration requires explicit child evidence. `sessionFormatV3ToV4.createStage()` refuses without that binding; an empty array explicitly declares no children. Persistence must collect the complete available direct-child set, while isolated transcript replay can supply its deliberately empty set. Keep the evidence unchanged for the catalog's lifetime. +Historical body restoration through `sessionFormatV3ToV4` requires only the requested Session. It preserves existing catalog entries without reading children. Offline fixture preparation may explicitly supply child evidence through `createSessionFormatCatalogWithChildren()` to append missing catalog facts; an empty array requests no backfill. Keep supplied evidence unchanged for the catalog’s lifetime. ```text const catalog = createSessionFormatCatalogWithChildren(childFacts) @@ -154,7 +154,7 @@ Catalog version 0 requires string `childId`, nonnegative safe-integer `childCrea The stage considers parent catalog records only after the final inherited cut. Every inherited marker discards earlier catalog candidates without interpreting their payloads. Missing entries append after all source events, sorted by creation time then child id, with dense new sequences. Their time is the final source event's time, or header creation time for an empty log. They neither enter the model surface nor change the inherited count. -Storage supplies the complete recognizable child set within its root and rechecks membership and physical revisions during preparation, memo reuse, and publication. Incomplete descriptor evidence only prevents that child’s backfill. Unreadable headers that prevent classifying membership, unsupported selected generations, or source drift refuse the operation. The package itself reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, and publication. +The supplemental evidence rules apply only when a caller requests catalog completion. Runtime JSONL migration does not collect child evidence; it migrates each Session on its own open. The package reads no files; [persistence](../session-persistence-jsonl/README.md) owns encoding, locks, cancellation, source revision checks, and publication. ### Sequence references and inheritance diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md index a92ae34a73..67148ff09c 100644 --- a/packages/session/session-format-v3-to-v4/README.zh.md +++ b/packages/session/session-format-v3-to-v4/README.zh.md @@ -48,7 +48,7 @@ kind: "package-library" const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader) ``` -历史正文恢复要求显式的子级证据。没有该绑定时,`sessionFormatV3ToV4.createStage()` 会拒绝;空数组明确声明没有子级。持久化层必须收集完整的可用直属子级集合,而隔离的 transcript 回放可以提供其刻意为空的集合。Catalog 存续期间,证据必须保持不变。 +通过 `sessionFormatV3ToV4` 恢复历史正文只需要所请求的会话。它保留已有 catalog 条目,不读取子会话。离线夹具准备可以通过 `createSessionFormatCatalogWithChildren()` 显式传入子会话证据,追加缺失的 catalog 事实;空数组表示不回填。传入证据在 catalog 生命周期内必须保持不变。 ```text const catalog = createSessionFormatCatalogWithChildren(childFacts) @@ -154,7 +154,7 @@ Catalog version 0 要求字符串 `childId`、非负安全整数 `childCreatedAt Stage 只把最终继承截点之后的父目录记录作为候选。每个 inherited marker 都会丢弃更早的目录候选,不解释其载荷。缺失项追加在所有源事件之后,按创建时间、child id 排序,并使用连续的新序号。时间取最后一个源事件的 time;空日志则取 header 创建时间。这些记录既不进入模型表面,也不改变继承计数。 -存储层提供其根目录内完整的可识别子级集合,并在准备、复用 memo 与发布时复查成员及物理版本。不完整的 descriptor 证据只阻止补填对应子 Session 的目录项。无法据此判断成员关系的不可读 header、不受支持的所选代际或源变化都会拒绝操作。本包不读取文件;[持久化层](../session-persistence-jsonl/README.zh.md)负责编码、锁、取消与发布。 +补充证据规则仅适用于调用者请求 catalog 补全时。运行时 JSONL 迁移不收集子会话证据,每个会话在自身 open 时独立迁移。本包不读取文件;[持久化](../session-persistence-jsonl/README.zh.md) 负责编码、锁、取消、源文件版本检查和发布。 ### 序号引用与继承 diff --git a/packages/session/session-format-v3-to-v4/src/migration.ts b/packages/session/session-format-v3-to-v4/src/migration.ts index 3b1c1de60a..d3359baed8 100644 --- a/packages/session/session-format-v3-to-v4/src/migration.ts +++ b/packages/session/session-format-v3-to-v4/src/migration.ts @@ -10,7 +10,7 @@ import { namespaceV3OpaqueEvent, RELEASED_V3_EVENT_TYPES } from './extension-ide import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts' import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts' -/** Header-only migration declaration; body restoration requires explicit child evidence. */ +/** Convert one Session independently, preserving its existing catalog entries. */ export const sessionFormatV3ToV4 = defineSessionFormatMigration({ name: '@deepseek-ai/dsh-session-format-v3-to-v4', fromVersion: 3, @@ -19,15 +19,13 @@ export const sessionFormatV3ToV4 = defineSessionFormatMigration({ assertReleasedV3Header(header) return { ...header, version: 4 } }, - createStage() { - throw new SessionFormatUnsupportedMigrationError('V3 catalog migration requires explicit historical child facts, including an empty array for a parent without children') - }, + createStage: input => new ReleasedV3ToV4Stage(input, []), validateTargetHeader: assertReleasedV4Header, }) /** * Bind one parent's historical child evidence to its V3→V4 migration. - * @param children - complete child evidence retained unchanged for the lifetime of this declaration; an empty array declares no children. + * @param children - complete child evidence retained unchanged for the lifetime of this declaration; an empty array requests no backfill. * @returns an adjacent migration that creates independent stages with the supplied evidence. */ export function createSessionFormatV3ToV4(children: readonly SessionFormatJsonValue[]): SessionFormatMigration { diff --git a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts index 7b1ad7e3c0..08069d659a 100644 --- a/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts +++ b/packages/session/session-format-v3-to-v4/tests/catalog-completion.spec.ts @@ -71,9 +71,9 @@ describe('V3 parent catalog completion', () => { expect(() => migrate([opaque], [])).toThrow('complete version 0') }) - it('requires explicit corpus facts and refuses conflicting identities', () => { + it('accepts independent migration and refuses conflicting supplemental identities', () => { expect(() => sessionFormatV3ToV4.createStage({ sourceHeader: header, targetHeader: { ...header, version: 4 }, - sourceKind: 'decoded', sourceInheritedEventCount: 0 })).toThrow('explicit historical child facts') + sourceKind: 'decoded', sourceInheritedEventCount: 0 })).not.toThrow() expect(() => migrate([], [{ ...child, mode: 'unknown' }])).toThrow('complete version 0') expect(() => migrate([{ type: 'subagent/catalog', seq: 0, time: 2, data: child }], [{ ...child, childCreatedAt: 3 }])).toThrow('conflicts') }) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 1589a1d5c7..6d1541c724 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 96c333d9f845943ff44c8d5d539c40d07b9dc0f6 -README.zh.md: 71126dd909c92aabdee5c07d23505f708dc5e038 +README.md: 2581be5ccdee5e5539e383ac0598ea1a285b6376 +README.zh.md: 7eddf277cc82e078283961f3309c979214e0cb9f diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 96c333d9f8..2581be5ccd 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -79,11 +79,11 @@ The current-generation scanner applies the current codec owner’s structural ad ### Reading the logs -`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. +`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Revisions identify only the selected Session file, independently of other Sessions. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. -Historical body preparation completes the parent catalog through [V3→V4](../session-format-v3-to-v4/README.md). It scans headers, decodes only direct children one at a time, and retains compact descriptor evidence plus source revisions. It rechecks membership and revisions before returning preparation, reusing it, and publishing. Source drift invalidates preparation: read open retries preparation once automatically, while write open refuses publication. Diagnostics name the changed, added, removed, or unreadable child path. An unsupported selected generation or unreadable header anywhere else in the scan refuses migration because it cannot establish complete membership. Ordinary listing still skips those files. Missing, unsupported, or multiple child descriptors prevent only that child’s catalog backfill; existing parent entries and child logs remain available. Invalid fields in exactly one supported own descriptor still report an unsupported format; malformed child events, JSON records, and compressed data report corruption at the child path. Filesystem errors and cancellation retain their original errors. Current V4 opens bypass this scan and validate catalog fields, uniqueness, and current delivery ownership before exposing events. +Historical preparation reads and migrates only the requested Session. It preserves existing parent catalog events without collecting child descriptors or validating other Session headers and bodies. Child corruption, unsupported generations, and child publication cannot invalidate a parent preparation. The [subagent service](../../subagent/subagent/README.md) discovers missing child entries from headers; opening a selected child performs its own migration and reports its own errors. Current reads validate catalog fields, uniqueness, and delivery ownership before exposing events. -Historical `stat` and `list` revisions require metadata work proportional to the root’s Session count. Fresh body preparation scans all selected headers and decodes direct-child bodies; memo reuse still scans membership and checks revisions. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat that work. Current V4 body reads and revisions avoid the historical corpus scan. See the [measured costs and diagnostic command](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements). +Historical revisions and memo reuse require no corpus fingerprint or child-body scan. Listing still enumerates Session headers; targeted generation lookup may enumerate directories to locate an uncached id. Read-only access never publishes an upgrade, so cold processes and evicted preparations repeat only the requested Session’s decoding. The [diagnostic measurements](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md#catalog-scan-measurements) retain the earlier eager-scan baseline. ----- diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 71126dd909..7eddf277cc 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -79,11 +79,11 @@ kind: "package-reference" ### 读取日志 -`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 +`open(id, 'read'|'write')` 选择最高规范代次。当前输入走普通快速路径。对于历史输入,读 open 解码并迁移源文件一次,校验当前逻辑结果,不发布后继文件。写 open 复用按源版本缓存的准备结果,或重新准备,再分块写同目录临时文件,经 Worker Thread 校验、复核源文件版本后,以不覆盖方式发布当前后继文件。源文件字节保持不变。准备后的源文件变化会拒绝该次写 open,已返回的逻辑历史不变;后续写 open 重新准备。缓存前深度冻结的事件图标记为 `shared-frozen`,读取和切片(包括空切片)保持这一状态;只有尚未物化的待写日志报告 `detached`。`stat(id)` 和 `list()` 只选择并转换最高代次的头部,不读事件行或启动迁移;快照携带所选文件的 `sizeBytes` 和尽力获取的版本标识。版本标识只对应所选会话文件,与其他会话无关。配置 `compression: 'none'` 时,外部工具可以直接读取逐行文本;压缩默认格式必须经后端读取。 -历史正文准备通过 [V3→V4](../session-format-v3-to-v4/README.zh.md) 补齐父目录:扫描 header,仅逐个解码直属子 Session,保留紧凑的 descriptor 证据及来源修订。在准备返回、复用和发布前重新检查成员集合与修订。来源变化会使准备缓存失效:只读打开自动重新准备一次,写打开则拒绝发布。诊断指出发生变化、新增、删除或无法读取的子日志路径。扫描中其他任何被选中的不支持代际或不可读 header 都会拒绝迁移,因为无法确认成员集合完整。普通列举仍跳过这些文件。子 descriptor 缺失、版本不受支持或存在多个时,只跳过该子 Session 的目录补填;已有父目录项和子日志仍可读取。恰好一个受支持的自身 descriptor 字段无效时,仍会报告格式不支持;子事件、JSON 记录或压缩数据畸形会在子日志路径报告损坏。文件系统错误与取消保留原始错误。当前 V4 打开跳过此扫描,并在暴露事件之前检查目录字段、唯一性及当前投递归属。 +历史准备只读取并迁移所请求的会话。它保留已有父会话 catalog 事件,不收集子会话 descriptor,也不校验其他会话的头部或正文。子会话损坏、不支持的代次和子会话后继发布不会使父会话准备结果失效。[子代理服务](../../subagent/subagent/README.zh.md) 从头部发现缺失的子会话条目;打开选中的子会话时才独立迁移并报告其错误。当前读取在暴露事件前校验 catalog 字段、唯一性和交付归属。 -历史格式的 `stat` 与 `list` 修订号需要与根目录 Session 数量成正比的元数据工作。首次正文准备扫描所有所选 header 并解码直属子正文;复用准备缓存仍扫描成员集合并检查修订。只读访问从不发布升级,因此冷进程与被淘汰的准备缓存会重复这些工作。当前 V4 正文读取和修订号避开历史全库扫描。见[实测成本与诊断命令](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements)。 +历史版本标识和缓存复用不需要全库指纹或子会话正文扫描。列表仍枚举会话头部;定位尚未缓存的 id 时,定向代次查找可能枚举目录。只读访问不发布升级,因此冷进程和缓存淘汰后只重复所请求会话的解码。[诊断测量](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md#catalog-scan-measurements) 保留此前主动扫描的基线。 ----- diff --git a/packages/session/session-persistence-jsonl/src/catalog-migration.ts b/packages/session/session-persistence-jsonl/src/catalog-migration.ts deleted file mode 100644 index 77e3d8b43e..0000000000 --- a/packages/session/session-persistence-jsonl/src/catalog-migration.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** Collect historical discovery facts without recursively preparing related current generations. */ - -import { stat } from 'node:fs/promises' -import { basename } from 'node:path' -import { historicalSessionFormatCatalog, sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' -import { historicalChildCatalogSource } from '@deepseek-ai/dsh-session-format-v3-to-v4' -import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' -import { SessionFormatError, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format' -import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import { parseGenerationLogFilename } from './format.ts' -import type { JsonlCompression } from './format.ts' -import { JsonlGenerationSourceChangedError, readDecodedJsonlSource } from './generation.ts' -import type { JsonlPhysicalIdentity } from './generation.ts' - -/** Per-parent supplemental facts and the source checks required before serving or publishing them. */ -interface PreparedCatalogFacts { - readonly facts: readonly SessionFormatJsonObject[] - /** @returns resolves while every decoded child still has the captured physical revision. */ - validate(): Promise -} - -/** - * Collect each related child's own descriptor through existing historical codecs. - * @param parentId - parent whose incoming migration consumes these facts. - * @param sources - header-indexed direct children in the selected source corpus. - * @param compression - configured source encoding. - * @param signal - cancellation forwarded through each source read. - * @returns compact facts; complete child event arrays are released after extraction. - */ -export async function prepareCatalogFacts( - parentId: SessionId, - sources: readonly { readonly header: SessionHeader; readonly path: string }[], - compression: JsonlCompression, - signal: AbortSignal, -): Promise { - const facts: SessionFormatJsonObject[] = [] - const witnesses: { path: string; identity: JsonlPhysicalIdentity }[] = [] - for (const source of sources) { - signal.throwIfAborted() - const version = parseGenerationLogFilename(basename(source.path), compression) - if (version === undefined) throw new SessionFormatUnsupportedMigrationError(`unrecognized historical child generation ${source.path}`) - let restored: Awaited> - try { - restored = await readDecodedJsonlSource(source.path, version, compression, { - createRestore: header => (version <= 3 ? historicalSessionFormatCatalog : sessionFormatCatalog).createRestore(header, { - recovery: 'recoverable', validation: 'current', - }), - }, signal) - } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedMigrationError) { - throw new SessionFormatUnsupportedError( - `child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, - { kind: 'jsonl', path: source.path }, - ) - } - if (error instanceof SessionFormatError) { - throw new SessionPersistenceCorruptionError(`child Session ${source.header.id}: ${error.message} (raw log: ${source.path})`, { cause: error }) - } - throw error - } - const header = restored.artifact.header - if (header.id !== source.header.id || header.createdAt !== source.header.createdAt - || header.parentSession !== parentId || header.origin !== 'subagent' - || ['cwd', 'isSeeded', 'delegationDepth', 'agentPreset'].some(key => header[key] !== source.header[key as keyof SessionHeader])) { - throw new JsonlGenerationSourceChangedError(source.path) - } - facts.push({ ...historicalChildCatalogSource(restored.artifact), sourcePath: source.path }) - witnesses.push({ path: source.path, identity: restored.identity }) - } - return { - facts, - async validate() { - for (const witness of witnesses) { - const current = await stat(witness.path, { bigint: true }) - if (current.dev !== witness.identity.dev || current.ino !== witness.identity.ino - || current.size !== witness.identity.size || current.mtimeNs !== witness.identity.mtimeNs - || current.ctimeNs !== witness.identity.ctimeNs) throw new JsonlGenerationSourceChangedError(witness.path) - } - }, - } -} diff --git a/packages/session/session-persistence-jsonl/src/generation.ts b/packages/session/session-persistence-jsonl/src/generation.ts index ff01e9a038..001c2dbc40 100644 --- a/packages/session/session-persistence-jsonl/src/generation.ts +++ b/packages/session/session-persistence-jsonl/src/generation.ts @@ -28,7 +28,6 @@ import { constants, createZstdCompress } from 'node:zlib' import { Session } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import { SessionFormatError } from '@deepseek-ai/dsh-session-format' import type { SessionFormatArtifact, SessionFormatJsonValue, @@ -69,8 +68,6 @@ export interface JsonlGenerationFormatAdapter { /** Inputs for preparing one historical generation and publishing its current successor later. */ export interface PrepareJsonlMigrationOptions { - /** Revalidate related source facts before preparation returns and immediately before publication. */ - readonly validateRelatedSources?: () => Promise /** Immutable generation selected by the backend resolver. */ readonly sourcePath: string /** Version selected from the source filename and independently checked against its header. */ @@ -903,7 +900,6 @@ async function publishPreparedMigration( throw new Error('staged session generation changed during verification') } await internals.barrier('before-source-check', 1) - await options.validateRelatedSources?.() const beforePublish = await internals.fs.stat(sourcePath) if (identity(beforePublish) !== identity(sourceIdentity)) { throw new JsonlGenerationSourceChangedError(sourcePath) @@ -978,7 +974,6 @@ async function prepareMigration( if (artifact.header.version !== format.currentVersion) { throw new Error(`format migration returned v${artifact.header.version}, expected v${format.currentVersion}`) } - await options.validateRelatedSources?.() const sourceIdentity = source.identity let publication: Promise | undefined return { @@ -1029,31 +1024,3 @@ export function createJsonlGenerationRuntime( } const defaultGenerationRuntime = createJsonlGenerationRuntime() - -/** - * Read one stable source through the shared streaming parser without publishing a generation. - * @param path - selected source generation path. - * @param version - physical source version identified by its filename. - * @param compression - source encoding. - * @param format - codec/restore factory, independent of current-generation publication. - * @param signal - cancellation observed during source reads and decode yields. - * @returns decoded artifact and physical source identity for later revalidation. - * @throws SessionFormatError for physical decoding failures; storage, cancellation, and unsupported migration errors retain their category. - */ -export async function readDecodedJsonlSource( - path: string, - version: number, - compression: JsonlCompression, - format: Pick, - signal?: AbortSignal, -): Promise<{ artifact: SessionFormatArtifact; identity: JsonlPhysicalIdentity }> { - const source = await readStableJsonlFile(path, signal) - let artifact: SessionFormatArtifact - try { - artifact = await decodeStreamingMigration(source.bytes, compression, version, format, undefined, signal) - } catch (error: unknown) { - if (signal?.aborted || error instanceof SessionFormatError) throw error - throw new SessionFormatError(String(error), { cause: error }) - } - return { artifact, identity: source.identity } -} diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 6e7e7b5628..3efafa4b6d 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -9,7 +9,6 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { - createSessionFormatCatalogWithChildren, SessionFormatUnsupportedMigrationError, sessionFormatCatalog, } from '@deepseek-ai/dsh-session-format-catalog' @@ -18,7 +17,7 @@ import { open, mkdir, readdir, realpath, link, rm, stat, truncate } from 'node:f import { dirname, join, resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' -import { createHash, randomBytes } from 'node:crypto' +import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, SessionFormatUnsupportedError, SessionPersistenceCorruptionError, @@ -45,7 +44,6 @@ import { } from './zstd.ts' import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts' import { verifyCurrentGenerationInWorker } from './migration-verifier.ts' -import { prepareCatalogFacts } from './catalog-migration.ts' import { JsonlGenerationSourceChangedError, JsonlGenerationUnsupportedMigrationError, @@ -124,7 +122,6 @@ interface CurrentStoredLog extends StoredLogBase { /** A migrated historical generation retained until an explicit write open publishes it. */ interface PreparedStoredLog extends StoredLogBase { readonly status: 'prepared' - readonly validateRelatedSources: () => Promise readonly publication: { readonly source: ResolvedJsonlGeneration readonly value: PreparedJsonlMigration @@ -255,7 +252,7 @@ class JsonlSessionPersistence extends SessionPersistence { private compression: JsonlCompression private rootEncodingCheck: Promise | undefined private readonly tracker = new JsonlBackendTracker(this.name) - private readonly generationFormat: Omit + private readonly generationFormat: JsonlGenerationFormatAdapter /** * Bounded LRU of parsed, validated stored logs keyed by session id and * guarded by the stat-derived revision, so an immediate cold-read handoff @@ -281,6 +278,9 @@ class JsonlSessionPersistence extends SessionPersistence { this.compression = config.compression ?? DEFAULT_COMPRESSION this.generationFormat = { currentVersion: sessionFormatCatalog.currentVersion, + createRestore: header => sessionFormatCatalog.createRestore(header, { + recovery: 'recoverable', validation: 'transformed', + }), encodeHeader: (header, inheritedEventCount) => sessionFormatCatalog.encodeCurrentHeader(header, inheritedEventCount), encodeEvent: event => sessionFormatCatalog.encodeCurrentEvent(event), @@ -348,13 +348,7 @@ class JsonlSessionPersistence extends SessionPersistence { if (pending !== undefined) { return this.tracker.adopt(new JsonlSessionHandle(this, id, pending.header, 'read', { cursor: 0, materialized: false, inheritedEventCount: pending.inheritedEventCount })) } - let stored: StoredLog - try { - stored = await this.requireStoredLog(id, options?.signal) - } catch (error: unknown) { - if (!(error instanceof JsonlGenerationSourceChangedError)) throw error - stored = await this.requireStoredLog(id, options?.signal) - } + const stored = await this.requireStoredLog(id, options?.signal) let state: StorageHandleState if (stored.status === 'prepared') { state = { @@ -454,9 +448,7 @@ class JsonlSessionPersistence extends SessionPersistence { options?.signal?.throwIfAborted() return { header, - revision: selected.sourceVersion < SESSION_FORMAT_VERSION - ? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`) - : fileRevision(identity), + revision: fileRevision(identity), sizeBytes: Number(identity.size), } } catch (error: unknown) { @@ -481,8 +473,6 @@ class JsonlSessionPersistence extends SessionPersistence { // predate the scan), so create-to-list visibility never has a hole. const pending = [...this.tracker.pendingEntries()] const artifacts = await this.listArtifacts(signal) - const corpusRevision = artifacts.some(artifact => artifact.sourceVersion < SESSION_FORMAT_VERSION) - ? await this.historicalCorpusRevision(signal) : undefined for (const artifact of artifacts) { signal?.throwIfAborted() try { @@ -491,9 +481,7 @@ class JsonlSessionPersistence extends SessionPersistence { listed.add(artifact.header.id) snapshots.push({ header: artifact.header, - revision: artifact.sourceVersion < SESSION_FORMAT_VERSION - ? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`) - : fileRevision(identity), + revision: fileRevision(identity), sizeBytes: Number(identity.size), }) } catch (error: unknown) { @@ -586,12 +574,6 @@ class JsonlSessionPersistence extends SessionPersistence { signal.throwIfAborted() const memoized = this.coldLogMemo.get(id) if (memoized?.status === 'prepared' && memoized.revision === sourceRevision) { - try { - await memoized.validateRelatedSources() - } catch (error: unknown) { - this.coldLogMemo.delete(id) - throw this.generationFailure(id, selected, error) - } this.coldLogMemo.delete(id) this.coldLogMemo.set(id, memoized) return memoized @@ -627,33 +609,13 @@ class JsonlSessionPersistence extends SessionPersistence { signal: AbortSignal, ): Promise { let prepared: Awaited> - let validateRelatedSources: () => Promise try { - const children = async () => (await this.listArtifacts(signal, selected.sourcePath)) - .filter(source => source.header.origin === 'subagent' && source.header.parentSession === id) - const sources = await children() - const related = await prepareCatalogFacts(id, sources, this.compression, signal) - const membership = sources.map(source => source.path).sort() - validateRelatedSources = async () => { - const current = (await children()).map(source => source.path).sort() - const before = new Set(membership) - const after = new Set(current) - const changed = current.find(path => !before.has(path)) ?? membership.find(path => !after.has(path)) - if (changed !== undefined) throw new JsonlGenerationSourceChangedError(changed) - await related.validate() - } prepared = await prepareJsonlMigration({ sourcePath: selected.sourcePath, sourceVersion: selected.sourceVersion, currentPath: selected.currentPath, compression: this.compression, - format: { - ...this.generationFormat, - createRestore: header => createSessionFormatCatalogWithChildren(related.facts).createRestore(header, { - recovery: 'recoverable', validation: 'transformed', - }), - }, - validateRelatedSources, + format: this.generationFormat, verifyCurrentFile: verifyCurrentGenerationInWorker, validateHistoricalHeader: headerValue => this.validateSourceIdentity( selected, @@ -672,7 +634,6 @@ class JsonlSessionPersistence extends SessionPersistence { validateStoredEvents(meta, events, { kind: 'jsonl', path: selected.sourcePath }) const stored: PreparedStoredLog = { status: 'prepared', - validateRelatedSources, meta, ...freezeStoredEvents(events), tornTruncateTo: undefined, @@ -1031,28 +992,8 @@ class JsonlSessionPersistence extends SessionPersistence { return sources } - /** Historical logical events depend on the corpus, including members with unreadable headers. */ - private async historicalCorpusRevision(signal?: AbortSignal): Promise { - const paths = (await this.listGenerations(signal)).map(source => source.sourcePath).sort() - const hash = createHash('sha256') - for (const path of paths) { - signal?.throwIfAborted() - let revision: string - try { - revision = fileRevision(await stat(path, { bigint: true })) - } catch (error: unknown) { - if (!isENOENT(error)) throw error - revision = 'missing' - } - hash.update(JSON.stringify([path, revision])) - } - signal?.throwIfAborted() - return hash.digest('hex') - } - private async listArtifacts( signal?: AbortSignal, - migrationSource?: string, ): Promise> { signal?.throwIfAborted() await this.ensureRootEncoding() @@ -1061,26 +1002,14 @@ class JsonlSessionPersistence extends SessionPersistence { const ids = new Set() for (const selected of await this.listGenerations(signal)) { signal?.throwIfAborted() - if (migrationSource !== undefined && selected.sourceVersion > SESSION_FORMAT_VERSION) { - throw new SessionFormatUnsupportedError( - `catalog migration cannot inspect Session format v${selected.sourceVersion}; upgrade the harness (raw log: ${selected.sourcePath})`, - { kind: 'jsonl', path: selected.sourcePath }, - ) - } let header: SessionHeader | undefined try { header = await this.readGenerationHeader(selected, undefined, signal) } catch (error: unknown) { - if (error instanceof SessionFormatUnsupportedError && migrationSource === undefined) continue + if (error instanceof SessionFormatUnsupportedError) continue throw error } if (header === undefined) { - if (migrationSource !== undefined && selected.sourcePath !== migrationSource) { - throw new SessionFormatUnsupportedError( - `cannot establish complete catalog membership from an unreadable Session header (raw log: ${selected.sourcePath})`, - { kind: 'jsonl', path: selected.sourcePath }, - ) - } continue } if (ids.has(header.id)) { diff --git a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts index af86cdbfe6..ec9acb1b74 100644 --- a/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { afterEach, describe, expect, it } from 'vitest' @@ -7,7 +7,6 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { generationLogPath } from '../src/format.ts' -import { prepareCatalogFacts } from '../src/catalog-migration.ts' import { compressZstdFrame } from '../src/zstd.ts' const roots: string[] = [] @@ -57,30 +56,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('wrong Session') }) - it('does not publish incomplete membership after a child header becomes unreadable', async () => { - const f = await fixture() - const path = await f.write('child', [], true) - const broken = '{broken header\n' - await writeFile(path, compression === 'none' ? broken : await compressZstdFrame(broken)) - await expect(f.read()).rejects.toThrow(path) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(path) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - }) - - it('invalidates lightweight revisions on child membership and contents while retaining stable tokens', async () => { - const f = await fixture() - const revision = async () => (await f.ctx.sessionPersistence.stat(f.parent))!.revision - const first = await revision() - expect(await revision()).toBe(first) - await f.write('child', [f.descriptor], true) - const added = await revision() - expect(added).not.toBe(first) - expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(added) - await f.write('child', [f.descriptor, { type: 'feedback/record', seq: 1, time: 3, data: {} }], true) - expect(await revision()).not.toBe(added) - }) - it('reports malformed native catalog data without migration terminology', async () => { const f = await fixture() await f.write(f.parent, [{ type: 'subagent/catalog', seq: 0, time: 1, @@ -102,48 +77,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first) }) - it('invalidates historical revisions when selected children disappear or gain an opaque successor', async () => { - const f = await fixture() - const path = await f.write('child', [f.descriptor], true) - const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision - await rm(path) - const removed = (await f.ctx.sessionPersistence.stat(f.parent))!.revision - expect(removed).not.toBe(first) - await f.write('child', [], true, 5) - expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).not.toBe(removed) - expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).not.toBe(removed) - }) - - it('reports a corrupt child body at the child location', async () => { - const f = await fixture() - const catalog = { type: 'subagent/catalog', seq: 0, time: 1, - data: { version: 0, childId: 'grandchild', childCreatedAt: 3, mode: 'one-shot' } } - const path = await f.write('child', [catalog, { ...catalog, seq: 1 }], true, 4) - await expect(f.read()).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) - await expect(f.read()).rejects.toThrow(path) - }) - - it.each(['read', 'write'] as const)('locates physical child JSON corruption during %s access', async (access) => { - const f = await fixture() - const childPath = await f.write('child', [], true) - const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n' - // A sealed turn makes the malformed row corruption rather than a recoverable tail. - const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, - data: { turn: 1, reason: { kind: 'completed' } } }) + '\n' - await writeFile(childPath, compression === 'none' ? first + body - : Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)])) - const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) - const original = await Promise.all([readFile(parentPath), readFile(childPath)]) - const error: unknown = await f.ctx.sessionPersistence.open(f.parent, access).catch((error: unknown) => error) - expect(error).toBeInstanceOf(SessionPersistenceCorruptionError) - expect((error as Error).message).toContain(childPath) - expect((error as Error).message).not.toContain(parentPath) - expect((error as Error).message).toContain('row 1 is not valid JSON') - expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) - expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - }) - it('preserves opaque inherited catalogs through read preparation and write publication', async () => { const f = await fixture() const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) @@ -165,15 +98,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect(await readFile(parentPath)).toEqual(original) }) - it.skipIf(compression !== 'zstd')('locates invalid compressed child frames at the child path', async () => { - const f = await fixture() - const childPath = await f.write('child', [], true) - await writeFile(childPath, Buffer.concat([await readFile(childPath), Buffer.alloc(8)])) - const error: unknown = await f.read().catch((error: unknown) => error) - expect(error).toBeInstanceOf(SessionPersistenceCorruptionError) - expect((error as Error).message).toContain(childPath) - }) - it('keeps a complete parent catalog when a published child never writes a descriptor', async () => { const f = await fixture() const catalog = { type: 'subagent/catalog', seq: 0, time: 2, @@ -188,18 +112,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) }) - it.each([1, 2, 3])('backfills a missing catalog from descriptor v%i', async (version) => { - const f = await fixture() - const data = version === 1 ? { version, provider: 'spawn', label: 'old child' } - : { ...f.descriptor.data, version } - await f.write('child', [{ ...f.descriptor, data }], true) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { - childId: 'child', childCreatedAt: 2, mode: 'continuable', label: 'old child', - } }]) - const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - await writer.close() - }) - it.each(['missing', 'unknown', 'multiple'] as const)('publishes the parent without invented %s child discovery information', async (kind) => { const f = await fixture() const events = kind === 'missing' ? [] : kind === 'unknown' @@ -219,37 +131,6 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', .toEqual(compression === 'none' ? ['session.v3.jsonl', 'session.v4.jsonl'] : ['session.v3.jsonl.zstd', 'session.v4.jsonl.zstd']) }) - it.each([false, true])('refuses an opaque future generation before publishing membership (prepared=%s)', async (prepared) => { - const f = await fixture() - await f.write('child', [f.descriptor], true) - if (prepared) expect(await f.read()).toHaveLength(1) - await f.write('child', [], true, 5) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - expect((await readdir(dirname(generationLogPath(f.root, undefined, f.parent, 3, compression)))).filter(name => name !== 'session.lock')) - .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - }) - - it('requires a readable corpus even when an unknown header cannot identify its parent', async () => { - const f = await fixture() - await f.write('future-root', [], false, 5) - expect((await f.ctx.sessionPersistence.list()).map(row => row.header.id)).toEqual([f.parent]) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - }) - - it('preserves unsupported child decoding errors instead of reporting parent corruption', async () => { - const f = await fixture() - const path = await f.write('child', [{ type: 'future/required', seq: 0, time: 2, data: {} }], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.read()).rejects.toThrow(path) - }) - - it('locates incomplete historical descriptor fields at their child source', async () => { - const f = await fixture() - const path = await f.write('child', [{ ...f.descriptor, data: { version: 2, provider: 'spawn', mode: 'unknown' } }], true) - await expect(f.read()).rejects.toBeInstanceOf(SessionFormatUnsupportedError) - await expect(f.read()).rejects.toThrow(path) - }) - it.each(['read', 'write'] as const)('rejects duplicate own catalogs through a current %s open', async (access) => { const f = await fixture() const catalog = { type: 'subagent/catalog', seq: 0, time: 2, @@ -258,123 +139,73 @@ describe.each(['none', 'zstd'] as const)('historical catalog publication (%s)', await expect(f.ctx.sessionPersistence.open(f.parent, access)).rejects.toThrow('duplicate catalog child') }) - it('recollects a changed child within the same read open', async () => { - const f = await fixture() - await f.write('child', [f.descriptor], true) - expect(await f.read()).toHaveLength(1) - await f.write('child', [f.descriptor, { type: 'feedback/record', seq: 1, time: 3, data: { text: 'appended' } }], true) - expect(await f.read()).toHaveLength(1) - const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - await writer.close() - }) - - it('detects children added after preparation instead of publishing incomplete membership', async () => { - const f = await fixture() - expect(await f.read()).toEqual([]) - await f.write('child', [f.descriptor], true) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(generationLogPath(f.root, undefined, SessionId('child'), 3, compression)) - expect(await f.read()).toHaveLength(1) - const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') - await writer.close() - }) - - it('recollects newly available discovery fields before publishing a prepared parent', async () => { + it.each(['read', 'write'] as const)('isolates malformed child JSON until explicit %s access', async (access) => { const f = await fixture() const childPath = await f.write('child', [], true) - expect(await f.read()).toEqual([]) - await f.write('child', [f.descriptor], true) - await expect(f.ctx.sessionPersistence.open(f.parent, 'write')).rejects.toThrow(childPath) - const parentPath = generationLogPath(f.root, undefined, f.parent, 3, compression) - expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')) + const first = JSON.stringify({ ...f.header, id: 'child', origin: 'subagent', parentSession: f.parent, delegationDepth: 1 }) + '\n' + const body = '{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'completed' } } }) + '\n' + await writeFile(childPath, compression === 'none' ? first + body + : Buffer.concat([await compressZstdFrame(first), await compressZstdFrame(body)])) + const original = await readFile(childPath) + const parent = await f.ctx.sessionPersistence.open(f.parent, access) + try { expect((await parent.read()).events).toEqual([]) } finally { await parent.close() } + await expect(f.ctx.sessionPersistence.open(SessionId('child'), access)).rejects.toBeInstanceOf(SessionPersistenceCorruptionError) + expect(await readFile(childPath)).toEqual(original) + expect((await readdir(dirname(childPath))).filter(name => name !== 'session.lock')) .toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', data: { childId: 'child', label: 'old child' } }]) + expect(await f.read()).toEqual([]) + }) + + it.each([true, false])('opens the parent independently of unsupported related=%s generations', async (related) => { + const f = await fixture() + await f.write('future', [], related, 5) + expect(await f.read()).toEqual([]) + const parent = await f.ctx.sessionPersistence.open(f.parent, 'write') + await parent.close() + await expect(f.ctx.sessionPersistence.open(SessionId('future'), 'read')).rejects.toBeInstanceOf(SessionFormatUnsupportedError) + }) + + it('keeps unreadable child headers outside parent migration', async () => { + const f = await fixture() + const path = await f.write('child', [], true) + await writeFile(path, compression === 'none' ? '{broken\n' : await compressZstdFrame('{broken\n')) + expect(await f.read()).toEqual([]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') await writer.close() }) - it('reads current children without recursive migration and preserves current parent fast reads', async () => { + it('keeps revisions and prepared parent history independent of child membership and contents', async () => { const f = await fixture() - await f.write('child', [f.descriptor], true, 4) - expect(await f.read()).toHaveLength(1) - await f.write(f.parent, [], false, 4) - await f.write('child', [], true, 4) + const first = (await f.ctx.sessionPersistence.stat(f.parent))!.revision expect(await f.read()).toEqual([]) - }) - - it('refuses publication when a related child disappears', async () => { - const f = await fixture() const path = await f.write('child', [f.descriptor], true) - const header = { ...f.header, id: SessionId('child'), origin: 'subagent', parentSession: f.parent, createdAt: 2, delegationDepth: 1 } as SessionHeader - const prepare = () => prepareCatalogFacts(f.parent, [{ path, header }], compression, new AbortController().signal) - const prepared = await prepare() + expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first) + await f.write('child', [{ ...f.descriptor, data: { ...f.descriptor.data, label: 'changed' } }], true) + expect((await f.ctx.sessionPersistence.list()).find(row => row.header.id === f.parent)?.revision).toBe(first) await rm(path) - await expect(prepared.validate()).rejects.toMatchObject({ code: 'ENOENT' }) - await expect(prepare()).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await f.ctx.sessionPersistence.stat(f.parent))!.revision).toBe(first) + const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') + try { expect((await writer.read()).events).toEqual([]) } finally { await writer.close() } }) - it('rejects changed header identities and invalid source selection', async () => { + it.each([0, 1, 2, 3])('migrates a V%i child only on its own write open and preserves predecessors', async (version) => { const f = await fixture() - const path = await f.write('child', [f.descriptor], true) - const header = { ...f.header, id: SessionId('wrong-child'), origin: 'subagent', parentSession: f.parent } as SessionHeader - const signal = new AbortController().signal - await expect(prepareCatalogFacts(f.parent, [{ path, header }], compression, signal)).rejects.toThrow('changed') - await expect(prepareCatalogFacts(f.parent, [{ path: path + '.foreign', header }], compression, signal)).rejects.toThrow('unrecognized historical child generation') - const controller = new AbortController() - controller.abort(new Error('cancelled child collection')) - await expect(prepareCatalogFacts(f.parent, [{ path, header }], compression, controller.signal)).rejects.toThrow('cancelled child collection') - }) - - it.each([0, 1, 2])('composes a V%i parent and child through all preceding edges', async (version) => { - const f = await fixture() - const initial = generationLogPath(f.root, undefined, f.parent, 3, compression) - await rm(initial) + await rm(generationLogPath(f.root, undefined, f.parent, 3, compression)) const parentPath = await f.write(f.parent, [], false, version) const childPath = await f.write('child', [f.descriptor], true, version) - const before = await Promise.all([readFile(parentPath), readFile(childPath)]) - expect(await f.read()).toMatchObject([{ type: 'subagent/catalog', seq: 0, data: { childId: 'child', mode: 'continuable', label: 'old child' } }]) + const original = await Promise.all([readFile(parentPath), readFile(childPath)]) + expect(await f.read()).toEqual([]) const writer = await f.ctx.sessionPersistence.open(f.parent, 'write') await writer.close() - expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(before) - expect((await readdir(dirname(parentPath))).filter(name => name !== 'session.lock')).toHaveLength(2) - }) - - it('lists historical headers, prepares read-only membership, and publishes an unchanged-prefix successor', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-catalog-migration-')) - roots.push(root) - const parent = SessionId('parent') - const child = SessionId('child') - const header = { type: 'session', version: 3, id: parent, createdAt: 1, isSeeded: false, delegationDepth: 0 } - const childHeader = { ...header, id: child, createdAt: 2, origin: 'subagent', parentSession: parent, delegationDepth: 1 } - const descriptor = { type: 'subagent/descriptor', seq: 0, time: 2, data: { version: 3, mode: 'one-shot', provider: 'spawn', label: 'old child' } } - const sourcePaths: string[] = [] - for (const [meta, events] of [[header, []], [childHeader, [descriptor]]] as const) { - const path = generationLogPath(root, undefined, meta.id, 3, compression) - sourcePaths.push(path) - await mkdir(dirname(path), { recursive: true }) - const first = Buffer.from(JSON.stringify(meta) + '\n') - const body = Buffer.from(events.map(event => JSON.stringify(event) + '\n').join('')) - await writeFile(path, compression === 'none' ? Buffer.concat([first, body]) - : Buffer.concat([await compressZstdFrame(first), ...(body.length === 0 ? [] : [await compressZstdFrame(body)])])) - } - const original = await Promise.all(sourcePaths.map(path => readFile(path))) - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(JsonlSessionPersistence, { root, compression }) - expect((await ctx.sessionPersistence.list()).map(row => row.header.id).sort()).toEqual([child, parent]) - const reader = await ctx.sessionPersistence.open(parent, 'read') - let expected - try { - expect(reader.header.version).toBe(4) - expected = (await reader.read()).events - expect(expected).toEqual([{ type: 'subagent/catalog', seq: 0, time: 1, data: { - version: 0, childId: child, childCreatedAt: 2, mode: 'one-shot', label: 'old child', - } }]) - expect(await readdir(dirname(sourcePaths[0]!))).toEqual([compression === 'none' ? 'session.v3.jsonl' : 'session.v3.jsonl.zstd']) - } finally { await reader.close() } - const writer = await ctx.sessionPersistence.open(parent, 'write') - try { expect((await writer.read()).events).toEqual(expected) } finally { await writer.close() } - expect(await Promise.all(sourcePaths.map(path => readFile(path)))).toEqual(original) - const reopened = await ctx.sessionPersistence.open(parent, 'read') - try { expect((await reopened.read()).events).toEqual(expected) } finally { await reopened.close() } + const childSuccessor = generationLogPath(f.root, undefined, SessionId('child'), 4, compression) + await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) + const childReader = await f.ctx.sessionPersistence.open(SessionId('child'), 'read') + try { expect((await childReader.read()).events).toEqual([f.descriptor]) } finally { await childReader.close() } + await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) + const childWriter = await f.ctx.sessionPersistence.open(SessionId('child'), 'write') + await childWriter.close() + expect((await readFile(childSuccessor)).length).toBeGreaterThan(0) + expect(await Promise.all([readFile(parentPath), readFile(childPath)])).toEqual(original) }) }) diff --git a/packages/session/session-persistence-jsonl/tests/generation.spec.ts b/packages/session/session-persistence-jsonl/tests/generation.spec.ts index 4d398dca4b..398066f704 100644 --- a/packages/session/session-persistence-jsonl/tests/generation.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/generation.spec.ts @@ -23,7 +23,6 @@ import { JsonlGenerationTargetConflictError, JsonlGenerationUnsupportedMigrationError, prepareJsonlMigration, - readDecodedJsonlSource, verifyJsonlCurrentGeneration, type JsonlGenerationFormatAdapter, type PrepareJsonlMigrationOptions, @@ -87,19 +86,6 @@ function header(version: number, id = 'generation-test'): Record { - const path = join(await tempRoot(), 'session.v3.jsonl') - await writeFile(path, line(header(3))) - const controller = new AbortController() - const reason = new Error('child decoding cancelled') - await expect(readDecodedJsonlSource(path, 3, 'none', { - createRestore() { - controller.abort(reason) - throw reason - }, - }, controller.signal)).rejects.toBe(reason) -}) - const assistantUsage = { inputTokens: 3, outputTokens: 2 } const assistantReplayState = { response: { id: 'response' } } diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 114b7f364e..d2f3e3868a 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1211,7 +1211,7 @@ describe('JsonlSessionPersistence: immutable format generations', () => { .rejects.toThrow(/released v0 physical header lacks required member "type"/) }) - it('tracks a disappearing corpus member and propagates its storage faults in historical revisions', async () => { + it('keeps historical revisions independent of another Session storage faults', async () => { const parent = meta('corpus-revision-parent', '/work') const child = meta('corpus-revision-child', '/work') for (const header of [parent, child]) { @@ -1224,10 +1224,10 @@ describe('JsonlSessionPersistence: immutable format generations', () => { statFailure.error = Object.assign(new Error('member disappeared'), { code: 'ENOENT' }) const missing = await ctx.sessionPersistence.stat(parent.id) expect(missing).toBeDefined() - expect(missing?.revision).not.toBe(present?.revision) + expect(missing?.revision).toBe(present?.revision) expect((await ctx.sessionPersistence.stat(parent.id))?.revision).toBe(missing?.revision) statFailure.error = Object.assign(new Error('member denied'), { code: 'EACCES' }) - await expect(ctx.sessionPersistence.stat(parent.id)).rejects.toBe(statFailure.error) + expect((await ctx.sessionPersistence.stat(parent.id))?.revision).toBe(present?.revision) }) it('surfaces source-read storage faults and aborts unwrapped during migration', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f2d1e1d365..ba1f32a337 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 7d62e423189906c49d9479bd7a6dbd14b7303afd -README.zh.md: af62ad97bc4821222d46ed73509c823851953e1d +README.md: 8351ff4aac8c3585ab0230cf5dbd1c2a4613adc7 +README.zh.md: 90806e0728acf61178e3d9914ec4c7bf0fa1a83c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 7d62e42318..8351ff4aac 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -60,7 +60,7 @@ One-shot children run once and settle with a single result, plus an optional str ### Messaging, interrupting, and discovering -Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Direct-child discovery reads the parent-owned `subagentCatalog` projection. `listChildren(parentSessionId, signal?)` owns a live-preferred Session observation and returns the catalog asynchronously without reading child logs. It forwards cancellation and releases the observation after materialization. Materialization preserves parent event order in O(D) time for D facts. Complete descendant discovery retains the Session corpus and child identity projection; neither path loads or resumes a child Agent. +Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Direct-child discovery reads the parent-owned `subagentCatalog` projection and Session headers. `listChildren(parentSessionId, signal?)` returns catalog entries in parent event order, then header-discovered children with `mode: 'unresolved'`; it never opens child bodies. It forwards cancellation and releases the parent observation after materialization. Header enumeration costs O(N) for N Sessions, while catalog materialization costs O(D) for D facts. Complete descendant discovery explicitly reads child identity projections; neither path loads or resumes a child Agent. ### Failure and recovery diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index af62ad97bc..90806e0728 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" ### 消息、中断与发现 -每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行 prompt 会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。 直接子级发现读取 parent 自有的 `subagentCatalog` projection。`listChildren(parentSessionId, signal?)` 持有一次优先实时来源的 Session 观察,异步返回目录,不读取子级日志。它转发取消信号,并在物化后释放观察。物化以 O(D) 时间保留 D 条事实的父日志事件顺序。完整后代发现保留 Session 语料库与子级身份 projection;两条路径都不加载或恢复子级 Agent。 +每个存活 Agent 都可以通过 `sendMessage()` 向直接可继续子代理发送消息;驻留的可继续子代理也可以向直接父代理发送。工作中的目标在最近步骤通过 Steer 接收消息,空闲目标启动新轮次,只有直接子代理支持冷恢复。父代理可以随时中断运行中的后代或列出子代理。浏览器续接独立选择 Queue 或 Steer,支持图片:Host 在子代理收件箱接收消息前准入并持久化图片,模型不支持图片输入时拒绝交付。直接子代理发现读取父会话的 `subagentCatalog` 投影和会话头部。`listChildren(parentSessionId, signal?)` 按父事件顺序返回 catalog 条目,再返回头部发现的 `mode: 'unresolved'` 子会话,不打开子会话正文。它传递取消信号,并在物化后释放父会话观察。N 个会话的头部枚举为 O(N),D 条事实的 catalog 物化为 O(D)。完整后代发现显式读取子会话身份投影;两条路径均不加载或恢复子 Agent。 ### 失败与恢复 diff --git a/packages/subagent/subagent/src/control-types.ts b/packages/subagent/subagent/src/control-types.ts index 10a8cfc426..ae478966db 100644 --- a/packages/subagent/subagent/src/control-types.ts +++ b/packages/subagent/subagent/src/control-types.ts @@ -9,6 +9,7 @@ import type { PromptContentPart } from '@deepseek-ai/dsh-attachment/types' import type { Branded } from '@deepseek-ai/dsh-brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentCatalogEntry } from './projection-types.ts' /** * Client-minted identity of one browser prompt, persisted on the exact accepted @@ -71,6 +72,13 @@ export type SubagentListEntry = readonly reason: 'corrupt' | 'unsupported' | 'unavailable' } +/** A direct child whose identity is known from the catalog or awaits an explicit child read. */ +export type SubagentDiscoveryEntry = SubagentCatalogEntry | { + readonly id: SessionId + readonly createdAt: number + readonly mode: 'unresolved' +} + /** Durable parent/child address that selects subagent transport in the client. */ export type SubagentAddress = & { @@ -80,6 +88,8 @@ export type SubagentAddress = & ( | { readonly mode: 'one-shot' } | { readonly mode: 'continuable' } + /** Header-discovered child; opening it resolves its descriptor without granting continuation. */ + | { readonly mode: 'unresolved' } ) /** One human message addressed to a continuable direct child. */ diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0262c37476..406bf9374b 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -76,7 +76,7 @@ import type { SubagentDescendantListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' import { establishCatalogChild, subagentCatalogProjectionDefinition } from './catalog.ts' -import type { SubagentCatalogEntry } from './projection-types.ts' +import type { SubagentDiscoveryEntry } from './control-types.ts' import { deliverSubagentPrompt } from './internal.ts' export type {} from './catalog.ts' @@ -366,15 +366,15 @@ export class SubagentRuntime extends TypertRemoteService { } /** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. + * Read the parent's catalog and discover missing direct children from Session headers. * The service owns and releases the live-preferred Session observation. * @param parentSessionId - parent whose direct children are requested. * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. + * @returns catalog children in event order, then unresolved children whose bodies remain unread. * @throws {@link SubagentError} when query or catalog projection is unavailable. * @throws SessionQueryError when the parent cannot be read or the query is cancelled. */ - listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { + listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise { return listSubagentChildren(this.ctx, parentSessionId, signal) } diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 9de66e771c..8c3b96c0e0 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -1,7 +1,8 @@ /** * Direct-child discovery from a parent-owned `subagent/catalog` projection, - * plus complete descendant-tree enumeration from the Session corpus. A direct - * listing owns one parent observation and reads no child log. Descendant + * with header-only discovery of missing entries, plus complete descendant-tree + * enumeration from the Session corpus. A direct listing observes one parent + * and reads no child log. Descendant * enumeration retains the complete corpus and the child identity projection * because ordinary Sessions and one-shot children remain traversal nodes. * @@ -17,7 +18,7 @@ import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-se import type { SubagentListEntry } from './control-types.ts' import { SubagentError } from './error.ts' import type { SubagentIdentityProjection } from './projection-types.ts' -import type { SubagentCatalogEntry } from './projection-types.ts' +import type { SubagentDiscoveryEntry } from './control-types.ts' export type { SubagentListEntry } from './control-types.ts' @@ -61,14 +62,14 @@ interface PositionedCandidate { * @param ctx - context carrying the Session query service. * @param parentSessionId - parent whose direct children are requested. * @param signal - cancellation forwarded to the Session observation. - * @returns direct-child rows in parent catalog event order. + * @returns catalog rows in event order, followed by header-discovered children without reading their bodies. * @throws {@link SubagentError} when query or catalog projection is unavailable. */ export async function listChildren( ctx: Context, parentSessionId: SessionId, signal?: AbortSignal, -): Promise { +): Promise { const query = ctx.get('sessionQuery') if (query === undefined) { throw new SubagentError( @@ -86,7 +87,15 @@ export async function listChildren( 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE', ) } - return entries + const discovered: SubagentDiscoveryEntry[] = [...entries] + const known = new Set(entries.map(entry => entry.id)) + const records = await query.listSessions(signal) + for (const { header } of records) { + if (header.origin === 'subagent' && header.parentSession === parentSessionId && !known.has(header.id)) { + discovered.push({ id: header.id, createdAt: header.createdAt, mode: 'unresolved' }) + } + } + return discovered } /** diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 84bcd8d6be..b472c074d6 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -184,7 +184,7 @@ function mutateStoredHeader( } describe('SubagentRuntime.listChildren', () => { - it('lists historical children through a migrated parent while preserving V3 files', async () => { + it('discovers historical children from headers while leaving their bodies unread', async () => { const { ctx, root } = await setup([], { compression: 'none' }) const parent = SessionId('historical-parent') const child = SessionId('historical-child') @@ -204,16 +204,18 @@ describe('SubagentRuntime.listChildren', () => { return { path, content } }) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') expect(await ctx.subagents.listChildren(parent)).toEqual([{ - id: child, createdAt: 2, mode: 'continuable', label: 'historical child', + id: child, createdAt: 2, mode: 'unresolved', }]) + expect(observe.mock.calls.map(([id]) => id)).toEqual([parent]) for (const { path, content } of originals) { expect(readFileSync(path, 'utf8')).toBe(content) expect(readdirSync(dirname(path))).toEqual(['session.v3.jsonl']) } }) - it('lists provider-established children from the parent catalog without a corpus or child read', async () => { + it('lists provider-established children from the parent catalog without a child body read', async () => { const { ctx, parent } = await setup([textResponse('once'), textResponse('again')]) const oneShot = await ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'finish once' }], @@ -234,7 +236,7 @@ describe('SubagentRuntime.listChildren', () => { { id: continuableId, label: 'continuable child', mode: 'continuable' }, ]) expect(children.every(child => Number.isFinite(child.createdAt))).toBe(true) - expect(listSessions).not.toHaveBeenCalled() + expect(listSessions).toHaveBeenCalledOnce() expect(observeSession).toHaveBeenCalledOnce() }) @@ -338,7 +340,7 @@ describe('SubagentRuntime.listChildren', () => { } expect(chunkLengths).toEqual([1, ...Array.from({ length: 16 }, () => 64)]) const entries = await ctx.subagents.listChildren(parent.id) - expect(listSessions).not.toHaveBeenCalled() + expect(listSessions).toHaveBeenCalledOnce() expect(entries).toHaveLength(1_025) expect(entries.find(entry => entry.id === 'child-064')).toEqual({ id: SessionId('child-064'), createdAt: 64, mode: 'one-shot', diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index b8478ed970..d234a3b96e 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/tool-subagent-control/README.md -README.md: 2cfa000f1ba9f7950b28369a9b7bd3523610b579 -README.zh.md: f2154fde965899e7b4f30271846bce52414b010d +README.md: 1be0e2b15f6ff36235275f659770c0c92557006e +README.zh.md: 3ee5e80aabd97df50feee46ea9183dee2916faf2 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 2cfa000f1b..1be0e2b15f 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -54,7 +54,7 @@ Stops only the target's current turn: queued messages stay parked until a later ### list_agents -Lists the continuable children below the calling agent: `children` (default) reads direct children from the parent catalog without opening child logs; `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running` or `inactive`. One-shot children are intentionally absent because they cannot accept `send_message`, and unreadable candidates appear as diagnostics only in `descendants` scope. +Lists the continuable children below the calling agent: `children` (default) combines parent catalog facts with header discovery without opening child logs; unresolved identities appear as `unavailable` diagnostics. `descendants` explicitly reads identities throughout the tree in stable pre-order and includes durable direct-parent ids and depths. Status comes from the live Agent registry: `running` or `inactive`. Known one-shot children are omitted because they cannot accept `send_message`. ----- @@ -150,7 +150,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -One line per continuable child in stable catalog order: ` [] —