From 9d2a3bf2b7987e82d29c715fec2b772f9f3fc66a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 13:41:31 +0800 Subject: [PATCH 1/9] feat(document): decouple shared Office conversion from preview --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 14 +- ...-executable-sdk-runtime-distribution.zh.md | 14 +- ...9-14-independent-libreoffice-kit.i18n.yaml | 6 + .../2026-09-14-independent-libreoffice-kit.md | 29 + ...26-09-14-independent-libreoffice-kit.zh.md | 29 + ...6-09-15-bounded-office-rendering.i18n.yaml | 6 + .../2026-09-15-bounded-office-rendering.md | 33 + .../2026-09-15-bounded-office-rendering.zh.md | 33 + ...26-09-15-platform-office-engines.i18n.yaml | 6 + .../2026-09-15-platform-office-engines.md | 29 + .../2026-09-15-platform-office-engines.zh.md | 29 + THIRD_PARTY_NOTICES.md | 8 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- apps/desktop/scripts/prepare-dsh.ts | 4 + apps/desktop/scripts/prepare-package-set.ts | 11 +- apps/desktop/scripts/runtime-file-policy.ts | 4 + .../desktop/tests/prepare-package-set.spec.ts | 23 +- .../desktop/tests/runtime-file-policy.spec.ts | 5 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 + docs/capability-seams.zh.md | 6 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 51 ++ docs/config-catalog.zh.md | 51 ++ docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/document-render.i18n.yaml | 6 + docs/subsystems/document-render.md | 62 ++ docs/subsystems/document-render.zh.md | 62 ++ docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 10 + docs/subsystems/workspace.zh.md | 10 + packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + packages/api/workspace-files/README.i18n.yaml | 4 +- packages/api/workspace-files/README.md | 2 + packages/api/workspace-files/README.zh.md | 2 + packages/api/workspace-files/src/index.ts | 16 +- .../workspace-files/tests/read-all.spec.ts | 11 + packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 3 + packages/bundle/web-app/package.json | 4 +- .../web-app/tests/document-conversion.e2e.ts | 76 +++ .../tests/fixtures/document-conversion.docx | Bin 0 -> 869 bytes packages/document/README.i18n.yaml | 6 + packages/document/README.md | 51 ++ packages/document/README.zh.md | 51 ++ .../README.i18n.yaml | 6 + .../document-render-libreoffice/README.md | 97 +++ .../document-render-libreoffice/README.zh.md | 97 +++ .../document-render-libreoffice/package.json | 43 ++ .../document-render-libreoffice/src/index.ts | 169 ++++++ .../document-render-libreoffice/src/output.ts | 39 ++ .../document-render-libreoffice/src/queue.ts | 232 ++++++++ .../tests/output.spec.ts | 64 ++ .../tests/provider.spec.ts | 269 +++++++++ .../tests/queue.spec.ts | 563 ++++++++++++++++++ .../document-render-libreoffice/tsconfig.json | 21 + .../document/document-render/README.i18n.yaml | 6 + packages/document/document-render/README.md | 75 +++ .../document/document-render/README.zh.md | 75 +++ .../document/document-render/package.json | 43 ++ .../document/document-render/src/index.ts | 44 ++ .../document/document-render/src/types.ts | 71 +++ .../document/document-render/tsconfig.json | 18 + .../webworker-runtime/README.i18n.yaml | 4 +- .../experimental/webworker-runtime/README.md | 1 + .../webworker-runtime/README.zh.md | 1 + .../webworker-runtime/src/module-proxies.ts | 1 + .../webworker-runtime/src/node/builtins.ts | 2 + .../node/external_packages/libreoffice-kit.ts | 12 + .../external_packages/replaced-externals.ts | 1 + .../tests/node/node-stubs.spec.ts | 9 + .../extensions/tool-cordis/src/api-catalog.ts | 53 ++ pnpm-lock.yaml | 184 +++++- pnpm-workspace.yaml | 7 + python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 6 +- python/sdk-runtime/README.zh.md | 6 +- python/sdk-runtime/hatch_build.py | 11 + python/sdk-runtime/pyproject.toml | 2 +- python/sdk-runtime/runtime-bootstrap.mjs | 15 +- .../src/deepseek_harness_runtime/__init__.py | 10 +- python/sdk/tests/test_release_version.py | 46 ++ python/sdk/tests/test_runtime_resolution.py | 54 ++ .../build-exe-for-python-sdk-office.spec.ts | 137 +++++ scripts/build-exe-for-python-sdk-office.ts | 94 +++ scripts/build-exe-for-python-sdk.ts | 26 +- scripts/build-python-release.py | 58 +- scripts/fixtures/python-sdk-office.mjs | 15 + scripts/gen-cordis-catalog.ts | 5 + scripts/gen-doc-graphs.ts | 6 + scripts/gen-third-party-notices.spec.ts | 23 + scripts/gen-third-party-notices.ts | 21 +- scripts/smoke-python-runtime.py | 77 ++- .../verify-default-product-isolation.spec.ts | 21 + scripts/verify-default-product-isolation.ts | 3 + .../verify-package-readme-model-experience.ts | 2 + scripts/verify-repository-references.spec.ts | 9 + scripts/verify-repository-references.ts | 4 +- tsconfig.base.json | 2 + tsconfig.host.json | 2 + 109 files changed, 3617 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md create mode 100644 .agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md create mode 100644 docs/subsystems/document-render.i18n.yaml create mode 100644 docs/subsystems/document-render.md create mode 100644 docs/subsystems/document-render.zh.md create mode 100644 packages/bundle/web-app/tests/document-conversion.e2e.ts create mode 100644 packages/bundle/web-app/tests/fixtures/document-conversion.docx create mode 100644 packages/document/README.i18n.yaml create mode 100644 packages/document/README.md create mode 100644 packages/document/README.zh.md create mode 100644 packages/document/document-render-libreoffice/README.i18n.yaml create mode 100644 packages/document/document-render-libreoffice/README.md create mode 100644 packages/document/document-render-libreoffice/README.zh.md create mode 100644 packages/document/document-render-libreoffice/package.json create mode 100644 packages/document/document-render-libreoffice/src/index.ts create mode 100644 packages/document/document-render-libreoffice/src/output.ts create mode 100644 packages/document/document-render-libreoffice/src/queue.ts create mode 100644 packages/document/document-render-libreoffice/tests/output.spec.ts create mode 100644 packages/document/document-render-libreoffice/tests/provider.spec.ts create mode 100644 packages/document/document-render-libreoffice/tests/queue.spec.ts create mode 100644 packages/document/document-render-libreoffice/tsconfig.json create mode 100644 packages/document/document-render/README.i18n.yaml create mode 100644 packages/document/document-render/README.md create mode 100644 packages/document/document-render/README.zh.md create mode 100644 packages/document/document-render/package.json create mode 100644 packages/document/document-render/src/index.ts create mode 100644 packages/document/document-render/src/types.ts create mode 100644 packages/document/document-render/tsconfig.json create mode 100644 packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts create mode 100644 scripts/build-exe-for-python-sdk-office.spec.ts create mode 100644 scripts/build-exe-for-python-sdk-office.ts create mode 100644 scripts/fixtures/python-sdk-office.mjs diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index ee3f631f38..2550d722da 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 665364e6d39a78f7ac497198f18ed9aa160a4cbd -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 8b39df71c615dc59f3ef1bf622a5736a70b085b3 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 45191be901f8df048d48798158c83fdfd8134737 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: b76035e6c81841e5eda4dcbcd575479996eff4b9 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 665364e6d3..45191be901 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,17 +42,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. +The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection. + ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its target-specific Office directory into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. @@ -70,6 +72,8 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. +The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (WASM on Linux, native on macOS/Windows), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. + ## Alternatives considered **Bare Node native SEA.** The injected main script must be a single CJS file, and the blob carries no filesystem and no module resolution, so a dynamic import of a bare specifier has nothing to resolve against; the only option is compiling plugins statically into the main script and registering them by hand — bypassing standard module resolution and hardcoding the plugin set, contrary to "configuration decides everything". The final route is in fact "the official SEA foundation + pkg's VFS/module-hook layer"; what was rejected is the bare use, not SEA itself. @@ -84,6 +88,6 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp ## Consequences -**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. +**Bought**: no system Node.js dependency on supported targets; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. -**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). +**Paid**: large Office engine resources remain external to the SEA archive, and the executable must travel with its required sidecars; source enters the distribution as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (`@yao-pkg/pkg` is an exact-pinned, pnpm-patched root devDependency; upgrading re-records the patch and is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial). diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 8b39df71c6..b76035e6c8 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,17 +42,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 +[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。 + ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 @@ -70,6 +72,8 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 +安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(Linux 为 WASM,macOS/Windows 为原生),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 + ## 曾考虑的替代方案 **裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与「配置决定一切」相悖。最终路线实际是「官方 SEA 基础 + pkg 的 VFS/模块钩子层」;否决的是裸用方式,而不是 SEA 本身。 @@ -84,6 +88,6 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 ## 后果 -**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 +**买到的**:受支持目标无需依赖系统 Node.js;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖 manifest 这两个真源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 -**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 +**付出的**:大型 Office 引擎资源保留在 SEA 归档之外,可执行文件必须与必需的伴随文件一起迁移;源码原样进入分发物(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(`@yao-pkg/pkg` 为精确钉版、带 pnpm 补丁的根 devDependency,升级需重录补丁,属显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml new file mode 100644 index 0000000000..cb10a38c27 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.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-14-independent-libreoffice-kit.md +2026-09-14-independent-libreoffice-kit.md: aaecb7468c16451ed812c9b2441e55e00abf6c98 +2026-09-14-independent-libreoffice-kit.zh.md: 8cea9890596bc53ddcbe346c60c654b49e0362c8 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md new file mode 100644 index 0000000000..aaecb7468c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -0,0 +1,29 @@ +# Agent Note: Independent LibreOffice kit ownership + +Status: implemented + +English | [中文](2026-09-14-independent-libreoffice-kit.zh.md) + +## Problem + +LibreOffice compilation, source patches, platform qualification, and large binary releases have a different maintenance cycle from Harness plugins. Keeping them in the application workspace expands routine CI and couples engine repairs to monorepo package rules. + +## Decision + +The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `DocumentRenderer` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. + +Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages. + +The upstream API declares platform engines as optional dependencies. The [platform engine decision](2026-09-15-platform-office-engines.md) supersedes the original required-WASM fallback policy. Desktop installs the kit as an external npm dependency. Python sidecars keep the Worker, selected engine, and their dependency closure on the real filesystem, outside the executable’s virtual filesystem. Conversion requires neither downloads nor GitHub credentials. + +The kit repository owns engine qualification and corresponding source materials. MPL-2.0 declarations, source availability, and redistribution notices accompany the API and engines; Harness retains these materials when packaging them. The notices check accepts the exact API, WASM, macOS ARM64/x64, and Windows ARM64/x64 package identities only at MPL-2.0; unrelated packages and changed license terms still reject. + +## Alternatives considered + +**Co-locate the public API and Core build in Harness.** This synchronizes source changes but makes application maintenance own long engine builds and special package rules. The Cordis provider is the application integration point; the reusable conversion API belongs with its engine tests. + +**Prepare GitHub Release archives before installation.** This requires separate authentication, hashes, decompression, workspace overrides, and distribution repacking. Published npm packages use the application's ordinary dependency installation and platform selection. + +## Consequences + +Changing the kit requires qualifying a release in its own repository and updating the Harness dependency versions and lockfile. A missing required npm package fails installation; Harness does not compile an engine to recover. Native and WASM conversion smokes validate the installed packages, while Desktop and Python checks cover application packaging. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md new file mode 100644 index 0000000000..8cea989059 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 独立 LibreOffice kit 的归属 + +Status: implemented + +[English](2026-09-14-independent-libreoffice-kit.md) | 中文 + +## Problem + +LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布的维护周期不同于 Harness 插件。将它们放在应用工作区会扩大常规 CI,并让引擎修复依赖 monorepo 包规则。 + +## Decision + +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentRenderer` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 + +kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 + +上游 API 将平台引擎声明为可选依赖。[平台引擎决策](2026-09-15-platform-office-engines.zh.md)取代最初强制携带 WASM 回退引擎的策略。Desktop 将 kit 作为外部 npm 依赖安装。Python sidecar 将 Worker、所选引擎及其依赖闭包保留在真实文件系统中,位于可执行文件的虚拟文件系统之外。转换无需下载或 GitHub 凭据。 + +kit 仓库负责引擎资格验证和对应源码材料。API 和引擎携带 MPL-2.0 声明、可访问源码及再分发声明;Harness 打包时保留这些材料。再分发声明检查仅在许可为 MPL-2.0 时接受 API、WASM、macOS ARM64/x64 和 Windows ARM64/x64 的精确包标识;无关包和改变后的许可条款仍被拒绝。 + +## Alternatives considered + +**将公开 API 和 Core 构建放在 Harness。** 这能同步源码变更,却让应用维护承担耗时的引擎构建和特殊包规则。Cordis provider 是应用集成点;可复用转换 API 应与引擎测试放在一起。 + +**安装前准备 GitHub Release 归档。** 这需要单独的鉴权、哈希、解压、工作区 overrides 和分发重打包。已发布的 npm 包使用应用常规的依赖安装与平台选择流程。 + +## Consequences + +更新 kit 需要在其独立仓库验证并发布新版本,再更新 Harness 依赖版本和锁文件。必需的 npm 包缺失时安装失败;Harness 不通过编译引擎恢复。原生和 WASM 转换冒烟验证已安装的包,Desktop 与 Python 检查覆盖应用打包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml new file mode 100644 index 0000000000..44528f3fc0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.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-15-bounded-office-rendering.md +2026-09-15-bounded-office-rendering.md: 539eb7e4824897861f350f294365f283678a7fee +2026-09-15-bounded-office-rendering.zh.md: 928d93a6500d6c7818f28dd20029648453d9f9cf diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md new file mode 100644 index 0000000000..539eb7e482 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -0,0 +1,33 @@ +# Agent Note: Bounded shared Office rendering + +Status: implemented + +English | [中文](2026-09-15-bounded-office-rendering.zh.md) + +## Problem + +Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion. + +## Decision + +The [Host provider](../../../../packages/document/document-render-libreoffice/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. + +A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. + +Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller. + +## Alternatives considered + +**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion. + +**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content. + +**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work. + +**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. + +## Consequences + +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. + +Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and renderer replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md new file mode 100644 index 0000000000..928d93a650 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 有界的共享 Office 渲染 + +Status: implemented + +[English](2026-09-15-bounded-office-rendering.md) | 中文 + +## 问题 + +Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。 + +## 决策 + +[宿主提供方](../../../../packages/document/document-render-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 + +有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 + +前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。 + +## 考虑过的替代方案 + +**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。 + +**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。 + +**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。 + +**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 + +## 后果 + +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 + +受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及渲染器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml new file mode 100644 index 0000000000..8fd429f5bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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-15-platform-office-engines.md +2026-09-15-platform-office-engines.md: fa8656eb7505b599f76640077f9b957fd867269c +2026-09-15-platform-office-engines.zh.md: eed1879b9a0bd5fe5056670c414c4db4b81d9d9b diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md new file mode 100644 index 0000000000..fa8656eb75 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md @@ -0,0 +1,29 @@ +# Agent Note: One Office engine per platform + +Status: implemented + +English | [中文](2026-09-15-platform-office-engines.zh.md) + +## Problem + +Installing WASM beside a usable native engine adds a second LibreOffice payload to application downloads and installed resources. The original fallback policy in [independent kit ownership](2026-09-14-independent-libreoffice-kit.md) requires that extra payload even on fixed-platform Desktop and Python distributions. + +## Decision + +Harness depends on the kit API and uses its platform-filtered optional engine dependencies. macOS and Windows install their matching ARM64 or x64 native package; Linux installs the shared WASM package. WASM declares Linux as its npm OS. The provider has no direct WASM dependency. Missing native engines on macOS/Windows reject converter creation instead of selecting WASM. + +Python sidecar assembly copies only the target engine and its dependency closure. Wheel packaging and runtime lookup require that engine; the relocated conversion smoke checks the selected backend. Desktop carries the ordinary npm installation in application resources. Engine compilation, qualification, and publication remain in the kit repository. + +## Alternatives considered + +**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every macOS/Windows installation. Fixed-platform distributions require their native engine to be present and tested instead. + +**Remove WASM on every platform.** Linux has no released native engine in this kit family. Keeping the shared WASM package on Linux preserves Office conversion without introducing a new native release target. + +**Make the Python Office sidecar optional.** The runtime carries the shared `dsh` CLI and its Web profile as well as the default SDK profile. Requiring the target engine gives the installed wheel a complete shipped profile set and reports an incomplete payload before launch. SDK and headless users also pay the engine download and installed-size cost. + +## Consequences + +macOS/Windows distributions omit the WASM download and expanded assets. A missing native package becomes an installation defect that must be repaired; conversion does not download an engine. Linux retains its WASM resource and font requirements. The kit’s engine-selection tests and actual npm installation tests cover platform selection; Harness sidecar, wheel, and runtime-resolution tests cover packaging requirements. New package bytes require kit qualification and matching dependency integrity records before publication. + +The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility. diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md new file mode 100644 index 0000000000..eed1879b9a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 每个平台使用一个 Office 引擎 + +Status: implemented + +[English](2026-09-15-platform-office-engines.md) | 中文 + +## Problem + +在可用的原生引擎之外安装 WASM,会为应用下载和安装资源增加第二份 LibreOffice 载荷。[独立 kit 归属](2026-09-14-independent-libreoffice-kit.zh.md)中最初的回退策略要求固定平台的 Desktop 和 Python 分发物也携带这份额外载荷。 + +## Decision + +Harness 依赖 kit API,并使用它按平台筛选的可选引擎依赖。macOS 和 Windows 安装匹配的 ARM64 或 x64 原生包;Linux 安装共享 WASM 包。WASM 的 npm OS 声明为 Linux。provider 不直接依赖 WASM。macOS/Windows 缺少原生引擎时拒绝创建转换器,不会选择 WASM。 + +Python sidecar 组装仅复制目标引擎及其依赖闭包。wheel 打包和运行时查找要求该引擎存在;迁移后的转换冒烟检查所选 backend。Desktop 在应用资源中携带常规 npm 安装结果。引擎编译、资格验证和发布仍由 kit 仓库负责。 + +## Alternatives considered + +**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个 macOS/Windows 安装。固定平台分发物改为要求其原生引擎存在且经过测试。 + +**在所有平台移除 WASM。** 当前 kit 家族没有已发布的 Linux 原生引擎。在 Linux 保留共享 WASM 包,既能继续转换 Office 文档,也无需引入新的原生发布目标。 + +**将 Python Office 伴随目录设为可选。** 运行时承载共享的 `dsh` CLI 及其 Web profile,而不只有默认 SDK profile。强制携带目标引擎使已安装 wheel 具备完整的内置 profile 集合,并在启动前报告载荷不完整。SDK 和 headless 用户也承担引擎的下载与安装体积。 + +## Consequences + +macOS/Windows 分发物省去 WASM 下载和展开资源。原生包缺失成为必须修复的安装缺陷;转换不会下载引擎。Linux 保留 WASM 的资源和字体要求。kit 的引擎选择测试与真实 npm 安装测试覆盖平台选择;Harness 的 sidecar、wheel 和运行时解析测试覆盖打包要求。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 + +[公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d4838dfe32..575964fb75 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,6 +36,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@anthropic-ai/sdk`](https://github.com/anthropics/anthropic-sdk-typescript) | MIT | | [`@babel/code-frame`](https://github.com/babel/babel) | MIT | | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | +| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | @@ -146,6 +147,13 @@ The installed SDK 0.3.263 declares the following optional platform packages. Eac | [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.263 | SEE LICENSE IN LICENSE.md | +## LibreOffice conversion kit + +`@deepseek-ai/libreoffice-kit`, `@deepseek-ai/libreoffice-kit-wasm`, `@deepseek-ai/libreoffice-kit-darwin-arm64`, `@deepseek-ai/libreoffice-kit-darwin-x64`, `@deepseek-ai/libreoffice-kit-win32-arm64`, `@deepseek-ai/libreoffice-kit-win32-x64` declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. + + ## Development-only npm dependencies External packages **directly declared** for development, tests, types, or tooling, without a runtime installation or browser-build relationship. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on that full closure. diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index de2415f74b..9dd51946fb 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: 393f41f82ed20fbc71b1659cd2417a8d7f9b5013 -README.zh.md: e5bdb032b47795dc28848561f08f0464f83e49d3 +README.md: 497f086217521dc081aa094d2a61a7ae7a362893 +README.zh.md: 687166b0c04994e503d62fb77bf07a2c030223d4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 393f41f82e..497f086217 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -122,7 +122,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t ### Runtime file selection -Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. +Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. + +The [Office conversion provider](../../packages/document/document-render-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e5bdb032b4..687166b0c0 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -122,7 +122,9 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS ### 运行时文件筛选 -生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。 +Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 + +[Office 转换提供方](../../packages/document/document-render-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 12c8f4bc55..18048375be 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -137,6 +137,10 @@ async function main(): Promise { throw new Error(`desktop runtime: missing private Host file ${file}`) } } + const officeEngine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` + if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', '@deepseek-ai', `libreoffice-kit-${officeEngine}`, 'prebuilds.json'))) { + throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) + } if (process.platform === 'darwin') { await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) await signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) diff --git a/apps/desktop/scripts/prepare-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts index f67d767e81..f22863e34c 100644 --- a/apps/desktop/scripts/prepare-package-set.ts +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto' import { constants, copyFileSync, + globSync, mkdirSync, readFileSync, readdirSync, @@ -13,6 +14,7 @@ import { } from 'node:fs' import { basename, join, resolve } from 'node:path' import { parseArgs } from 'node:util' +import * as yaml from 'js-yaml' import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES, @@ -49,13 +51,16 @@ function dependencyNames(manifest: Readonly>, section: s } /** - * Select the complete available first-party dependency closures rooted at dsh and its private Host. + * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages. * @param available - Packed packages indexed by package name. * @returns Selected packages sorted by name. */ export function selectDesktopPackageClosure( available: ReadonlyMap, ): PackedDesktopPackage[] { + const workspace = yaml.load(readFileSync(join(REPOSITORY_ROOT, 'pnpm-workspace.yaml'), 'utf8')) as { packages: string[] } + const workspaceNames = new Set(globSync(workspace.packages.map(pattern => `${pattern}/package.json`), { cwd: REPOSITORY_ROOT }) + .map(path => (JSON.parse(readFileSync(join(REPOSITORY_ROOT, path), 'utf8')) as { name: string }).name)) const selected = new Map() const visit = (name: string): void => { if (selected.has(name)) return @@ -65,8 +70,8 @@ export function selectDesktopPackageClosure( for (const section of REQUIRED_DEPENDENCY_SECTIONS) { for (const dependency of dependencyNames(packed.manifest, section)) { if (available.has(dependency)) visit(dependency) - else if (dependency.startsWith('@deepseek-ai/')) { - throw new Error(`desktop package set: ${name} requires unpacked internal package ${dependency}`) + else if (workspaceNames.has(dependency)) { + throw new Error(`desktop package set: ${name} requires unpacked package ${dependency}`) } } } diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 7e5e7d6649..9e6ea293ab 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -23,6 +23,10 @@ export function desktopRuntimeFileExclusion( const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1 const name = packageParts.slice(0, nameParts).join('/') const entry = packageParts.slice(nameParts).join('/') + if (name.startsWith('@deepseek-ai/libreoffice-kit-')) { + const engine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` + if (name !== `@deepseek-ai/libreoffice-kit-${engine}`) return 'LibreOffice other platform' + } if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { return 'fs-ext compiler output' } diff --git a/apps/desktop/tests/prepare-package-set.spec.ts b/apps/desktop/tests/prepare-package-set.spec.ts index 1601a0c7f6..85f26a12bb 100644 --- a/apps/desktop/tests/prepare-package-set.spec.ts +++ b/apps/desktop/tests/prepare-package-set.spec.ts @@ -46,21 +46,38 @@ describe('desktop package-set selection', () => { ]) }) - it('rejects a required internal package absent from the packed release inputs', () => { + it.each([ + '@deepseek-ai/dsh-base', '@deepseek-ai/cordis', '@deepseek-ai/node-addon-system', + ])('rejects required prepared package %s absent from the packed release inputs', (dependency) => { const available = new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { - dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' }, + dependencies: { [dependency]: '^1.0.0' }, })], ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', { dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, })], ]) - expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked internal package/u) + expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked package/u) expect(() => selectDesktopPackageClosure(new Map([ ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')], ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) }) + it('leaves independently published Office packages to npm resolution', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { + '@deepseek-ai/libreoffice-kit': '0.0.1', + '@deepseek-ai/libreoffice-kit-wasm': '0.0.1', + }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host')], + ]) + expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + '@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', + ]) + }) + it('requires the Desktop Host entry', () => { const files = [ 'package/lib/index.js', diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 837fd54bf1..283a4f5842 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -27,6 +27,8 @@ it('omits development artifacts while preserving executable modules, assets and 'node-pty/prebuilds/win32-x64/conpty.pdb', '@koromix/koffi-win32-x64/win32_x64/koffi.lib', '@mixmark-io/domino/test/entities.html', + '@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data', + '@deepseek-ai/libreoffice-kit-darwin-arm64/bin/libreoffice-kit', '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', ] const retained = [ @@ -44,6 +46,9 @@ it('omits development artifacts while preserving executable modules, assets and '@koromix/koffi-win32-x64/win32_x64/koffi.node', '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', '@img/sharp-win32-x64/lib/libvips-42.dll', + '@deepseek-ai/libreoffice-kit-win32-x64/bin/libreoffice-kit.exe', + '@deepseek-ai/libreoffice-kit-win32-x64/sources/core-source.json', + '@deepseek-ai/libreoffice-kit-win32-x64/licenses/MPL-2.0.txt', ] try { const runtime = runtimeFixture(source) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 7157fab563..153fe3a5ca 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: df39022715cf1000daca9fdeaf668e9cedf88bb1 -capability-seams.zh.md: fc9c8fd46375d77fb42fcc6cf0aef160527d100e +capability-seams.md: 90d77872b47c1ac610d566c29f6c172e74bd79f3 +capability-seams.zh.md: 62a0b545e47cfc6b3c5cf30fa76e3ca332c7ed7d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index df39022715..90d77872b4 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,6 +26,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_document_render["document-render"] + svc_documentRender["ctx.documentRender
Office to PDF rendering"] + pkg_document_render_libreoffice["document-render-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -283,6 +286,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions + pkg_document_render --> svc_documentRender + pkg_document_render_libreoffice --> svc_documentRender pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -533,6 +538,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | +| `ctx.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index fc9c8fd463..62a0b545e4 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,6 +28,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] + pkg_document_render["document-render"] + svc_documentRender["ctx.documentRender
Office to PDF rendering"] + pkg_document_render_libreoffice["document-render-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -285,6 +288,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions + pkg_document_render --> svc_documentRender + pkg_document_render_libreoffice --> svc_documentRender pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -535,6 +540,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | +| `ctx.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c049eab040..bfaed2cd89 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: c3e84ba47ad687915ae1c641e8600bd43a2102b6 -config-catalog.zh.md: b8b86030d96ebd8ea49cc6e545007a0c319720f3 +config-catalog.md: 146af3692b84672a09039ed56b19a54470c3c47f +config-catalog.zh.md: 8e140676fca4ce6ea81258c1e8b0cbd21e254ccf diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c3e84ba47a..146af3692b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,6 +548,56 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) + + +## `@deepseek-ai/dsh-document-render-libreoffice` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +Source: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) + ## `@deepseek-ai/dsh-experimental-agent-team` @@ -3798,6 +3848,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-document-render` — abstract `DocumentRenderer` ([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b8b86030d9..8e140676fc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -550,6 +550,56 @@ export interface Config { 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) + + +## `@deepseek-ai/dsh-document-render-libreoffice` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +源码: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) + ## `@deepseek-ai/dsh-experimental-agent-team` @@ -3800,6 +3850,7 @@ export interface Config { - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-document-render` — 抽象 `DocumentRenderer`([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 92e23f39f1..b0a4c765f9 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: b4d6aa226979525ef658f9cf1d33d5ca3e59dcf6 -README.zh.md: 9134fcf37789911aec05dcdc9e9c3dc036a33873 +README.md: e6c0e0d1b6f7428c6af40af110d744afda6fadbf +README.zh.md: d7b640a383e88c3aa0364682ddd8df2bf8ffce85 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index b4d6aa2269..e6c0e0d1b6 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -30,6 +30,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [mcp.md](mcp.md) | external MCP connections, scoped tools and resources, server instructions, protocol results, and configuration ownership | | [user-questions.md](user-questions.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts | +| [document-render.md](document-render.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [shell.md](shell.md) | the shell executor seam: `ShellExecRequest`/`Spec`, `ShellRunResult`, background `ShellProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 9134fcf377..d7b640a383 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -30,6 +30,7 @@ | [mcp.md](mcp.zh.md) | 外部 MCP 连接、作用域工具与资源、服务器指令、协议结果与配置归属 | | [user-questions.md](user-questions.zh.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.zh.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | +| [document-render.md](document-render.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | | [attachment.md](attachment.zh.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [shell.md](shell.zh.md) | shell 执行器 seam:`ShellExecRequest`/`Spec`、`ShellRunResult`、后台 `ShellProcess` 句柄 | | [subprocess.md](subprocess.zh.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | diff --git a/docs/subsystems/document-render.i18n.yaml b/docs/subsystems/document-render.i18n.yaml new file mode 100644 index 0000000000..ab3117feae --- /dev/null +++ b/docs/subsystems/document-render.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 docs/subsystems/document-render.md +document-render.md: f3e49fb45f2cc19956b6385cf7390825fa9d4c60 +document-render.zh.md: 79028858e9e3f95ac1a4618d3b9342b78aa29cf2 diff --git a/docs/subsystems/document-render.md b/docs/subsystems/document-render.md new file mode 100644 index 0000000000..f3e49fb45f --- /dev/null +++ b/docs/subsystems/document-render.md @@ -0,0 +1,62 @@ +# Document rendering + +English | [中文](document-render.zh.md) + +The [document package family](../../packages/document/README.md) converts Office files to PDFs on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns conversion, bounded admission, and transient PDF reuse. This subsystem creates no model-facing tool or Session event. + +## Ownership + +| Owner | Responsibility | +|---|---| +| [document-render](../../packages/document/document-render/README.md) | `ctx.documentRender`: authorized Office bytes and complete PDF results | +| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | +| [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | + +## Requests and results + +[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentRenderer.render(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentRenderError`. + +`DocumentRenderPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentRendererGeneration` brands a provider lifetime, and `DocumentRenderKey` brands its content identity; neither opaque value is parsed by consumers. + +| Result field | Meaning | +|---|---| +| `pdf` | Caller-owned `Uint8Array` containing the complete PDF | +| `missingFonts` | Requested document font families unavailable to this conversion | +| `cacheKey` | Opaque renderer generation plus extension/source-content identity | +| `generation` | Provider lifetime; replacement invalidates cached PDF reuse | + +The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. + +## Engine selection and limits + +The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. + +The [Host provider configuration](../../packages/document/document-render-libreoffice/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.documentRender` — `DocumentRenderer` (abstract seam) + +Load one provider subclass per context; consumers own source authorization. + +```ts cordis-catalog +/** + * Convert Office bytes without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +``` + +Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) + diff --git a/docs/subsystems/document-render.zh.md b/docs/subsystems/document-render.zh.md new file mode 100644 index 0000000000..79028858e9 --- /dev/null +++ b/docs/subsystems/document-render.zh.md @@ -0,0 +1,62 @@ +# 文档渲染 + +[English](document-render.md) | 中文 + +[document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 + +## 所有权 + +| 所有者 | 职责 | +|---|---| +| [document-render](../../packages/document/document-render/README.zh.md) | `ctx.documentRender`:已授权 Office 字节和完整 PDF 结果 | +| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | +| [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | + +## 请求和结果 + +[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentRenderer.render(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentRenderError` 拒绝。 + +`DocumentRenderPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentRendererGeneration` 表示提供方生命周期,`DocumentRenderKey` 表示其内容身份;消费者不解析这两种不透明值。 + +| 结果字段 | 含义 | +|---|---| +| `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | +| `missingFonts` | 本次转换无法使用的文档请求字体名称 | +| `cacheKey` | 不透明的渲染 generation 加扩展名与源内容身份 | +| `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | + +提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 + +## 引擎选择和限制 + +外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 + +[Host 提供方配置](../../packages/document/document-render-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.documentRender` — `DocumentRenderer` (abstract seam) + +Load one provider subclass per context; consumers own source authorization. + +```ts cordis-catalog +/** + * Convert Office bytes without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ +abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +``` + +Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) + diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index b362e8d475..d933a9055e 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/subsystems/workspace.md -workspace.md: 80ea3157dbd904e168e2809ce8aa0b8641c1b2d9 -workspace.zh.md: 727c612e58cdbe54b0ba0e6292803d4543e5a32e +workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e +workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 80ea3157db..50db26be22 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -386,6 +386,16 @@ Host Remote file reads and workspace directory observations over the composed fi */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +/** + * Read a complete authorized file within a Host consumer's reserved byte capacity. + * @param workspaceFileScope - Session authorization and execution scope. + * @param path - absolute or workspace-relative file path. + * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. + * @param signal - caller cancellation. + * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + */ +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise + /** * Read a complete file relative to another file's directory, including outside the workspace. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 727c612e58..fd4110041f 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -386,6 +386,16 @@ Host Remote file reads and workspace directory observations over the composed fi */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise +/** + * Read a complete authorized file within a Host consumer's reserved byte capacity. + * @param workspaceFileScope - Session authorization and execution scope. + * @param path - absolute or workspace-relative file path. + * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. + * @param signal - caller cancellation. + * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + */ +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise + /** * Read a complete file relative to another file's directory, including outside the workspace. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5ac75e2178..5893d269ed 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 113d5d11a0b108b84c0b8983602b7d99c0f79425 -README.zh.md: b86f98cc4c6ad8f748d3bd31d9a4bcaa7a10f27e +README.md: 5f759989eda6588e707a0c80c3035d0ba51ec950 +README.zh.md: 8a2784f5a3f99c9842b10d98d89b6d40936380b2 diff --git a/packages/README.md b/packages/README.md index 113d5d11a0..5f759989ed 100644 --- a/packages/README.md +++ b/packages/README.md @@ -56,6 +56,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and model-facing `workflow`/`ralph` tools | | [`webhook/`](webhook/README.md) | Verified external events, trusted rules, and fire-and-forget Workspace Sessions | | [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools | +| [`document/`](document/README.md) | Shared Host Office-to-PDF conversion | | [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index b86f98cc4c..8a2784f5a3 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -56,6 +56,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow`/`ralph` 工具 | | [`webhook/`](webhook/README.zh.md) | 已验证外部事件、受信规则与即发即弃 Workspace 会话 | | [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 | +| [`document/`](document/README.zh.md) | 共享宿主 Office 到 PDF 转换 | | [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 | | [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | | [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 | diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 0c9f859e28..7c954c743a 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/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/workspace-files/README.md -README.md: f0f9cb1532fa65954415e10a5e248b775cdff83a -README.zh.md: 8a510fa5a599037364095e221e7ecf6a8c7bb94a +README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91 +README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f0f9cb1532..9da773d652 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -82,6 +82,8 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. +Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte. + ----- diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 8a510fa5a5..56276e5da2 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -82,6 +82,8 @@ kind: "package-reference" 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 +Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。 + ----- diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 0205186879..e099056cd4 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -276,8 +276,22 @@ export class WorkspaceFiles extends TypertRemoteService { */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { + return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) + } + + /** + * Read a complete authorized file within a Host consumer's reserved byte capacity. + * @param workspaceFileScope - Session authorization and execution scope. + * @param path - absolute or workspace-relative file path. + * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. + * @param signal - caller cancellation. + * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + */ + async readAllBounded( + workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, + ): Promise { const { target, info } = await this.locateFile(workspaceFileScope, path, signal) - const limit = this.config.maxFileBytes + const limit = Math.min(this.config.maxFileBytes, maxBytes) if (info.size !== undefined && info.size > limit) { throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) } diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index bf331b8f19..8bf2470241 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -51,6 +51,17 @@ describe('workspaceFiles.readAll', () => { }) }) +it('applies the smaller Host reservation before reading a complete file', async () => { + await writeFile(join(harness.workspace, 'reserved'), '1234') + const read = vi.spyOn(harness.ctx.fs, 'readByteRange') + const files = harness.endpoint({ maxFileBytes: 8 }) + expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal()))) + .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } }) + expect(read).not.toHaveBeenCalled() + expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4) + expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 }) +}) + describe('workspaceFiles.readRelated', () => { it('resolves relative paths from the base file directory on the Host', async () => { await mkdir(join(harness.workspace, 'nested')) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 54536cd33e..807ca3fb09 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-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/web-app/README.md -README.md: c35bdf219c4c231e2ffde73a592b04070f96a4bc -README.zh.md: 9a4047525da16cfb8249e4732fc2102f95b9d89f +README.md: 0057516fb5bfba2708923e08af20f6e59f53f772 +README.zh.md: 1be494520d18311cae1f8dfca00c682b597f889d diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index c35bdf219c..0057516fb5 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. +The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-render` row mounts one lazy [Office conversion provider](../../document/document-render-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 9a4047525d..1be494520d 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -本组合包是一份 patch 加一个运行时粘合插件。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的 workspace 与 message-feedback 行使用共享的 `storageDomain` 服务。patch 重述 base 刻意省略的表层专属值,插入仅 Web 使用的宿主行与浏览器名录,然后把 agent 层改由 preset 承载;粘合插件负责 dist 服务、信任采样、提示词段落、bash 变量与就绪宣告。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-render` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-render-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a229cfe658..863fea96d5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,6 +225,9 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' + - id: document-render + name: '@deepseek-ai/dsh-document-render-libreoffice' + # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, and plain-text renderers. - id: ui-sidebar-documentpreview diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index de15dfa5ce..50bd19af36 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -122,7 +122,9 @@ "commander": "^15.0.0", "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^" + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", + "@deepseek-ai/dsh-document-render": "workspace:^", + "@deepseek-ai/dsh-document-render-libreoffice": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts new file mode 100644 index 0000000000..3cab818548 --- /dev/null +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -0,0 +1,76 @@ +/** The shipped shared provider converts Office bytes without presentation plugins. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-render-libreoffice' +import { expect, it, onTestFinished } from 'vitest' + +it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-conversion-loader-')) + const ctx = new Context() + onTestFinished(async () => { + try { await ctx.fiber.dispose() } + finally { await rm(directory, { recursive: true, force: true }) } + }) + const rows = loadOverlayPatches('conversion-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []) + .filter(row => row.name === '@deepseek-ai/dsh-document-render-libreoffice') + expect(rows.map(row => row.id)).toEqual(['document-render']) + const configured = applyEntryPatches(rows, [{ id: 'document-render', config: { maxConcurrentConversions: 1 } }], + (message) => { throw new Error(message) }) + const configPath = join(directory, 'cordis.yml') + await writeFile(configPath, JSON.stringify(configured)) + ctx.baseUrl = pathToFileURL(directory).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + // The Loader resolves the same source-plane module namespace as the test. + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier !== '@deepseek-ai/dsh-document-render-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) + return LibreOfficeProvider + }, + } as unknown as NonNullable + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'document-render')! + await entry.fiber!.await() + expect(ctx.get('documentRenderController')).toBeUndefined() + expect(ctx.get('skills')).toBeUndefined() + const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url)) + let reads = 0 + const request = (key: string): DocumentRenderRequest => ({ + extension: 'docx', priority: 'foreground', + source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length, + async read(signal, maxBytes) { + signal.throwIfAborted() + expect(bytes.length).toBeLessThanOrEqual(maxBytes) + reads++ + return { bytes: Uint8Array.from(bytes), version: 'fixture' } + }, + }, + }) + const provider = ctx.documentRender + const [first, second] = await Promise.all([provider.render(request('first')), provider.render(request('second'))]) + expect(reads).toBe(2) + expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-') + expect(first.cacheKey).toBe(second.cacheKey) + expect(first.pdf).toEqual(second.pdf) + expect(first.pdf).not.toBe(second.pdf) + expect(first.missingFonts).not.toBe(second.missingFonts) + first.pdf.fill(0) + first.missingFonts.push('caller-owned') + const cached = await provider.render(request('first')) + expect(reads).toBe(2) + expect(cached.pdf).toEqual(second.pdf) + expect(cached.missingFonts).toEqual(second.missingFonts) + await ctx.fiber.dispose() + expect(Buffer.from(cached.pdf).subarray(0, 5).toString()).toBe('%PDF-') + await expect(provider.render(request('first'))).rejects.toMatchObject({ code: 'unavailable' }) +}) diff --git a/packages/bundle/web-app/tests/fixtures/document-conversion.docx b/packages/bundle/web-app/tests/fixtures/document-conversion.docx new file mode 100644 index 0000000000000000000000000000000000000000..3cb118c895bd055950c97a22e474962c89522ae9 GIT binary patch literal 869 zcmWIWW@Zs#U|`??V#R>(-VLRPfUGh`1_ogu9qpW-SCX1n5+71okXjt8SCN}jJ7Fi^ zAp@Sa_@$@1RtPdPFVZ={#j~ej>aJWDy`y@^Z%;1b<&b#$|A&q~-*u0em^oi#R{E)& zV~i+&GU2qq$@Hb1Cl@|3+$s<#zfpCDpPKQ7gWQ#pr!Op=)@^EfBU;mJ`kpl5nx#UK zVMkvj%0x^(B(e6QMr8Zs_4XQ%)fb*Qxx3w3xN-_F*FGV(r{)i4-kz(W8XUawxJq=% zP1ffVvQ{*|i}joKKYQOD$yvuTs-CxRo6)3}_BcBI^!7&=eq8@sQ2+fCTL6le6RY)| zJAq#P1jO7x9AA{0Q>?ECB74tU^D!w3usv8Uz_(#@RD{HWO*gMqFftuh*6Zhy`1&m< zV$HEbMbGBf)Vs|oylOk`j?ZWtE}iw*`GZg1K7f5neo5}VZeV?R|Za(~-9kL|Q0 z=Z}iOy$q->-p%^U2NX$Kz(^7V(&hO@Df%h-$)&l#m;yynZotK?!v;KmL?`pFxU``+ z>5@bIH-ma$Urh zV7=IU54YgTf@F_-9+HKTCWQUCb*fq+ zaq8B@*LODlDoFQni&3~L{b-fJ=guFT%(2|%(l70fUyKYfeAs_@jnJ%zyEsxNuidfC zIO^uKM_2xf{x7<}FEj3y>FO`*+b`w6GuOSN*KoXiSABptBa;X-?sN(CH5fDig8*wP wMK=IFmLb|17#bKq1DQy1j;;|sun`)U0`0~K`T%cMHjol#AlwP0r!j$e0D&z`7ytkO literal 0 HcmV?d00001 diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml new file mode 100644 index 0000000000..d31937a0b5 --- /dev/null +++ b/packages/document/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/document/README.md +README.md: 4ccf8e9c7267b7442ee08a218b51e50a4909aae3 +README.zh.md: c91beff7d3f6a028d9a1553308714c0328ab35de diff --git a/packages/document/README.md b/packages/document/README.md new file mode 100644 index 0000000000..4ccf8e9c72 --- /dev/null +++ b/packages/document/README.md @@ -0,0 +1,51 @@ +--- +description: "Package map for Host Office conversion and reusable PDF results." +kind: "package-group" +--- + +# document/ — Office rendering + +English | [中文](README.zh.md) + +## Summary + +Convert authorized Office files to reusable PDFs on the Host. The rendering family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +Each package owns its configuration and lifetime rules; the subsystem reference describes their shared rendering operation. + +| Package | Role | ctx key | +|---|---|---| +| [document-render](document-render/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentRender` | +| [document-render-libreoffice](document-render-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentRender` | + +----- + + +## Related documentation + +Consumers own source authorization and presentation. + +- [Document rendering](../../docs/subsystems/document-render.md) — shared operation and generated service reference. +- [Independent kit ownership](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) — engine distribution and application integration. +- [Workspace Files](../api/workspace-files/README.md) — authorized bounded source reads. + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md new file mode 100644 index 0000000000..c91beff7d3 --- /dev/null +++ b/packages/document/README.zh.md @@ -0,0 +1,51 @@ +--- +description: "Host Office 转换和可复用 PDF 结果的包索引。" +kind: "package-group" +--- + +# document/ — Office 渲染 + +[English](README.md) | 中文 + +## 摘要 + +在宿主上将已授权的 Office 文件转换为可复用的 PDF。渲染包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +每个包负责自身配置和生命周期规则;子系统参考描述共享渲染操作。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [document-render](document-render/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentRender` | +| [document-render-libreoffice](document-render-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentRender` | + +----- + + +## 相关文档 + +消费者负责源文件授权与展示。 + +- [文档渲染](../../docs/subsystems/document-render.zh.md) — 共享操作和生成的服务参考。 +- [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。 +- [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。 + + +## 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/document/document-render-libreoffice/README.i18n.yaml b/packages/document/document-render-libreoffice/README.i18n.yaml new file mode 100644 index 0000000000..1fde4e33b9 --- /dev/null +++ b/packages/document/document-render-libreoffice/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/document/document-render-libreoffice/README.md +README.md: 3315498c928a5613809ad3f75a1d9fe3e4bc07d2 +README.zh.md: e39f5824d5f969d997914848ebdd6dc767186dc7 diff --git a/packages/document/document-render-libreoffice/README.md b/packages/document/document-render-libreoffice/README.md new file mode 100644 index 0000000000..3315498c92 --- /dev/null +++ b/packages/document/document-render-libreoffice/README.md @@ -0,0 +1,97 @@ +--- +description: "Host Office conversion with the independently published LibreOffice kit." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render-libreoffice + +English | [中文](README.zh.md) + +## Summary + +Convert Office documents to PDFs on the Host computer. macOS and Windows use their matching native LibreOffice engine; Linux uses Node WASM. The provider accepts DOC, DOCX, XLS, XLSX, PPT, and PPTX. OOXML conversion returns missing-font names; binary Office conversion returns an empty list. + +## 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 + +The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-render`. Independent compositions mount `@deepseek-ai/dsh-document-render-libreoffice` as a `cordis.yml` row. + +The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. + +| Field | Default | Meaning | +|---|---|---| +| `maxConcurrentConversions` | `2` | Maximum active converters; queued calls remain cancellable. | +| `timeoutMs` | `60000` | Conversion deadline after a converter is acquired. | +| `maxInputBytes` | `52428800` | Maximum source bytes. | +| `maxOutputBytes` | `104857600` | Maximum complete PDF bytes. | +| `maxImageResolution` | `192` | Maximum raster-image DPI; overrides the kit default of `144`. | +| `fontFallbacks` | Kit defaults | Ordered font-family preference groups; each group requires at least two names containing non-whitespace characters. | + +The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-document-render-libreoffice) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. + +The provider retains successful PDFs by renderer generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready. + +Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles. + +The defaults retain 8 PDFs/128 MiB and 64 source aliases, admit 32 readers and 8 queued jobs, reserve at most 100 MiB of source bytes, and allow one background conversion. These limits bound owned requests and binary payloads, not engine RSS, base64 transport expansion, caller-retained results, or PDF.js page rendering. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +Invalid engine metadata, missing required assets, and conversion errors reject the request instead of switching engines. The shared WASM engine uses LibreOffice's CPU image filter. Native conversion uses its separate platform engine. + +Each concurrent slot lazily creates and reuses one kit converter. The provider writes authorized input into a private temporary directory, reads a bounded regular PDF, and removes the directory before settling. Canceling an admitted reader does not block later queued work. Reader cancellation releases only that reader; the final reader and provider disposal cancel shared work. Disposal reports `unavailable` to outstanding readers and joins conversions and converter teardown. No runtime invariant companion is published because active operations and scratch cleanup have one lifetime owner. + +
+ +----- + + +## Further Exploration + +- [Rendering service](../document-render/README.md) — input and result ownership. +- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. + +----- + + +## Model Experience + +None, as this package converts bytes without model-facing tools, messages, or Session events. + +#### KV Cache effect + +None; conversion does not construct or modify model requests. + +## Known Limitations and Deferred Work + + + +- Conversion fidelity and installed native/WASM assets belong to `@deepseek-ai/libreoffice-kit`; this provider neither searches for system LibreOffice nor downloads an engine at runtime. +- Queue waiting time is not bounded by `timeoutMs`, which starts only when kit conversion begins. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/document/document-render-libreoffice/README.zh.md b/packages/document/document-render-libreoffice/README.zh.md new file mode 100644 index 0000000000..e39f5824d5 --- /dev/null +++ b/packages/document/document-render-libreoffice/README.zh.md @@ -0,0 +1,97 @@ +--- +description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文档。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render-libreoffice + +[English](README.md) | 中文 + +## 概述 + +在宿主计算机上将 Office 文档转换为 PDF。macOS 和 Windows 使用匹配的原生 LibreOffice 引擎;Linux 使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +[Web bundle](../../bundle/web-app/README.zh.md)以 `document-render` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-render-libreoffice`。 + +此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `maxConcurrentConversions` | `2` | 最大活跃转换器数量;排队调用仍可取消。 | +| `timeoutMs` | `60000` | 获取转换器后的转换期限。 | +| `maxInputBytes` | `52428800` | 最大源字节数。 | +| `maxOutputBytes` | `104857600` | 最大完整 PDF 字节数。 | +| `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 | +| `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 | + +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-render-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 + +提供方按渲染 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 + +准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 + +默认保留 8 个 PDF、128 MiB 和 64 个源别名,准入 32 个读取方与 8 个排队任务,最多预留 100 MiB 源字节,并允许一个后台转换。这些限制约束拥有的请求和二进制载荷,不约束引擎 RSS、base64 传输膨胀、调用方保留的结果或 PDF.js 页面渲染。 + +----- + + +## 理解实现 + +
+实现内部细节 — 点击展开 + +引擎元数据无效、必需资源缺失和转换错误会拒绝请求,不会切换引擎。共享的 WASM 引擎使用 LibreOffice 的 CPU 图像过滤器。原生转换使用独立的平台引擎。 + +每个并发槽按需创建并复用一个 kit 转换器。提供方将已授权输入写入私有临时目录,读取有大小上限的普通 PDF 文件,并在完成前删除目录。取消已准入的读取方不会阻塞后续排队工作。读取方取消只会释放该读取方;最后一个读取方和提供方卸载会取消共享工作。卸载向未完成读取方报告 `unavailable`,并等待转换及转换器清理结束。活跃操作和临时目录清理由同一生命周期负责,因此不发布运行时不变量伴随入口。 + +
+ +----- + + +## 延伸阅读 + +- [渲染服务](../document-render/README.zh.md) — 输入和结果所有权。 +- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 + +----- + + +## 模型体验 + +无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。 + +#### KV Cache effect + +无;转换不会构造或修改模型请求。 + +## 已知限制与延后工作 + + + +- 转换保真度和已安装的原生/WASM 资产由 `@deepseek-ai/libreoffice-kit` 负责;此提供方不查找系统 LibreOffice,也不在运行时下载引擎。 +- `timeoutMs` 仅在 kit 开始转换时计时,不限制队列等待时间。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/document/document-render-libreoffice/package.json b/packages/document/document-render-libreoffice/package.json new file mode 100644 index 0000000000..3adb8d4203 --- /dev/null +++ b/packages/document/document-render-libreoffice/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-document-render-libreoffice", + "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/document/document-render-libreoffice" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-document-render": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-document-render": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/libreoffice-kit": "0.0.1", + "@deepseek-ai/schemastery": "workspace:^" + } +} diff --git a/packages/document/document-render-libreoffice/src/index.ts b/packages/document/document-render-libreoffice/src/index.ts new file mode 100644 index 0000000000..21296d11f0 --- /dev/null +++ b/packages/document/document-render-libreoffice/src/index.ts @@ -0,0 +1,169 @@ +/** Host LibreOffice kit provider with reusable converters and private disk input/output. */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit' +import { DocumentRenderer, DocumentRenderError, DocumentRendererGeneration, type DocumentExtension, type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import z from '@deepseek-ai/schemastery' +import { readPdf } from './output.ts' +import { RenderQueue } from './queue.ts' + +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} + +/** Deployment defaults resolved before provider construction. */ +export const Config: z, Config> = z.object({ + maxConcurrentConversions: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(2), + maxQueuedJobs: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), + maxReaders: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(32), + maxSourceBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(104857600), + maxBackgroundConversions: z.natural().min(0).max(Number.MAX_SAFE_INTEGER).default(1), + maxCachedEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(8), + maxCachedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(134217728), + maxSourceEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(64), + timeoutMs: z.natural().min(1).max(2_147_483_647).default(60_000), + maxInputBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER - 1).default(50 * 1024 * 1024), + maxOutputBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER - 1).default(100 * 1024 * 1024), + maxImageResolution: z.natural().min(1).max(2_147_483_647).default(192), + maxArchiveEntries: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(10_000), + maxUncompressedBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(250 * 1024 * 1024), + fontDirectories: z.array(z.string().min(1)).extra('default', undefined), + fontFallbacks: z.array(z.array(z.string().pattern(/\S/)).min(2)).extra('default', undefined), + maxFontFiles: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(20_000), + maxFontFileBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(256 * 1024 * 1024), + maxLoadedFontBytes: z.natural().min(1).max(Number.MAX_SAFE_INTEGER).default(512 * 1024 * 1024), +}) + +interface Slot { + busy: boolean + converter?: Promise +} + +/** A provider lifetime owns all converters, queued calls, and temporary files. */ +export class LibreOfficeRenderer extends DocumentRenderer { + static Config = Config + readonly generation = DocumentRendererGeneration(randomUUID()) + private readonly slots: Slot[] = [] + private readonly queue: RenderQueue + private readonly options: ConverterOptions + + /** + * @param ctx - owning Host context. + * @param config - resolved rendering, font, and concurrency limits. + */ + constructor(ctx: Context, private readonly config: Config) { + super(ctx) + if (config.fontDirectories?.some(path => !isAbsolute(path))) throw new Error('fontDirectories must contain absolute paths.') + if (config.maxSourceBytes < config.maxInputBytes) throw new Error('maxSourceBytes must be at least maxInputBytes.') + const { fontDirectories, fontFallbacks, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, + maxArchiveEntries, maxUncompressedBytes, maxFontFiles, maxFontFileBytes, maxLoadedFontBytes } = config + this.options = { timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, maxArchiveEntries, maxUncompressedBytes, + maxFontFiles, maxFontFileBytes, maxLoadedFontBytes, + ...(fontDirectories === undefined ? {} : { fontDirectories }), + ...(fontFallbacks === undefined ? {} : { fontFallbacks }), + } + this.queue = new RenderQueue(config, this.generation, (bytes, extension, signal) => this.convert(bytes, extension, signal)) + ctx.effect(() => async () => { + await this.queue.dispose() + const results = await Promise.allSettled(this.slots.map(async (slot) => { + const converter = await slot.converter + await converter?.dispose() + })) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length > 0) throw new AggregateError(failures.map((result): unknown => result.reason), 'LibreOffice converter disposal failed.') + }) + } + + render(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + return this.queue.read(request, signal) + } + + private async convert(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { + signal.throwIfAborted() + let slot = this.slots.find(candidate => !candidate.busy) + if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) } + slot.busy = true + let directory: string | undefined + try { + if (slot.converter === undefined) { + slot.converter = createConverter(this.options).catch((error: unknown) => { + delete slot.converter + throw error + }) + } + const converter = await slot.converter + signal.throwIfAborted() + directory = await mkdtemp(join(tmpdir(), 'dsh-document-render-')) + const inputPath = join(directory, `source.${extension}`) + const outputPath = join(directory, 'converted.pdf') + await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal }) + signal.throwIfAborted() + const result = await converter.render({ inputPath, outputPath }, signal) + signal.throwIfAborted() + let pdf: Uint8Array + try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) } + catch (cause) { + if (cause instanceof DocumentRenderError) throw cause + throw new DocumentRenderError('invalid-output', 'The converter PDF could not be read.', { cause }) + } + signal.throwIfAborted() + return { pdf, missingFonts: result.missingFonts } + } catch (cause) { + signal.throwIfAborted() + if (cause instanceof DocumentRenderError) throw cause + const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined + switch (code) { + case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format': + case 'invalid-output': case 'timeout': case 'unavailable': + throw new DocumentRenderError(code, 'LibreOffice conversion failed.', { cause }) + default: throw new DocumentRenderError('failed', 'LibreOffice conversion failed.', { cause }) + } + } finally { + try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) } + finally { slot.busy = false } + } + } +} + +export default LibreOfficeRenderer diff --git a/packages/document/document-render-libreoffice/src/output.ts b/packages/document/document-render-libreoffice/src/output.ts new file mode 100644 index 0000000000..863c681020 --- /dev/null +++ b/packages/document/document-render-libreoffice/src/output.ts @@ -0,0 +1,39 @@ +/** Read only the bounded, regular PDF created inside a private task directory. */ +import { constants } from 'node:fs' +import { lstat, open } from 'node:fs/promises' +import { DocumentRenderError } from '@deepseek-ai/dsh-document-render' + +/** + * Refuse missing, link-shaped, oversized, truncated, and non-PDF output. + * @param path - kit output path in the provider-owned scratch directory. + * @param limit - inclusive PDF byte limit. + * @param signal - conversion lifetime. + * @returns complete bytes independent of the scratch file. + */ +export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise { + signal.throwIfAborted() + const entry = await lstat(path) + if (!entry.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + try { + const info = await file.stat() + if (!info.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') + if (info.size > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + const bytes = Buffer.alloc(info.size + 1) + let length = 0 + while (length < bytes.length) { + signal.throwIfAborted() + const read = await file.read(bytes, length, bytes.length - length, null) + if (read.bytesRead === 0) break + length += read.bytesRead + } + signal.throwIfAborted() + if (length > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + const pdf = bytes.subarray(0, length) + if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u) + || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) { + throw new DocumentRenderError('invalid-output', 'The converter did not produce a complete PDF.') + } + return pdf + } finally { await file.close() } +} diff --git a/packages/document/document-render-libreoffice/src/queue.ts b/packages/document/document-render-libreoffice/src/queue.ts new file mode 100644 index 0000000000..affcebfb3f --- /dev/null +++ b/packages/document/document-render-libreoffice/src/queue.ts @@ -0,0 +1,232 @@ +/** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */ +import { createHash } from 'node:crypto' +import { DocumentRenderError, DocumentRenderKey, type DocumentExtension, type DocumentRendererGeneration, + type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import type { Config } from './index.ts' + +type Converted = Pick +type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise +interface Reader { + job: Job + readonly source: string + readonly priority: DocumentRenderRequest['priority'] + readonly resolve: (value: DocumentRenderResult) => void + readonly reject: (error: unknown) => void + readonly cleanup: () => void +} +interface Job { + readonly request: DocumentRenderRequest + readonly controller: AbortController + readonly readers: Set + readonly sources: Set + priority: DocumentRenderRequest['priority'] + state: 'queued' | 'running' | 'finished' +} + +/** One renderer generation owns every queued source, conversion, reader, and retained PDF. */ +export class RenderQueue { + private readonly ready = new Map() + private readonly aliases = new Map() + private readonly sources = new Map() + private readonly digests = new Map() + private readonly queue: Job[] = [] + private readonly tasks = new Set>() + private readonly jobs = new Set() + private cachedBytes = 0 + private sourceBytes = 0 + private running = 0 + private background = 0 + private readers = 0 + private disposed = false + + /** + * @param config - validated queue, source, reader, and completed-result limits. + * @param generation - provider lifetime; prevents reuse after engine or font replacement. + * @param convert - executes one admitted conversion and settles after scratch cleanup. + */ + constructor(private readonly config: Config, private readonly generation: DocumentRendererGeneration, + private readonly convert: Convert) {} + + /** + * Admit metadata before loading source bytes and share conversion across authorized readers. + * @param request - already-authorized metadata and deferred bounded source read. + * @param signal - this reader's cancellation; the final reader cancels shared work. + * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early. + */ + async read(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + if (this.disposed) throw this.unavailable() + if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) { + throw new DocumentRenderError('input-too-large', 'The Office source exceeds maxInputBytes.') + } + const source = JSON.stringify([request.source.key, request.source.version, request.extension]) + const alias = this.aliases.get(source) + const cached = alias === undefined ? undefined : this.ready.get(alias) + if (cached !== undefined) { + this.ready.delete(cached.cacheKey) + this.ready.set(cached.cacheKey, cached) + this.aliases.delete(source) + this.aliases.set(source, cached.cacheKey) + return this.copy(cached) + } + const readerLimit = request.priority === 'background' ? this.config.maxReaders - 1 : this.config.maxReaders + if (this.readers >= readerLimit) throw this.busy() + if (request.priority === 'background' && this.config.maxBackgroundConversions === 0) throw this.busy() + let job = this.sources.get(source) + if (job === undefined) { + if (this.queue.length >= this.config.maxQueuedJobs) { + const obsolete = request.priority === 'foreground' ? this.queue.find(item => item.priority === 'background') : undefined + if (obsolete === undefined) throw this.busy() + this.fail(obsolete, this.busy()) + } + job = { request, priority: request.priority, controller: new AbortController(), readers: new Set(), sources: new Set([source]), state: 'queued' } + this.sources.set(source, job) + this.jobs.add(job) + this.queue.push(job) + } + const shared = job + if (request.priority === 'foreground') shared.priority = 'foreground' + this.readers++ + const promise = new Promise((resolve, reject) => { + const abort = (): void => { + this.release(reader) + const reason: unknown = signal?.reason + reject(reason instanceof Error ? reason : new Error('Document rendering cancelled', { cause: reason })) + if (reader.job.readers.size === 0) this.cancel(reader.job) + // Cancellation or demotion can unblock queued background work. + this.drain() + } + const reader: Reader = { job: shared, source, priority: request.priority, resolve, reject, cleanup: () => signal?.removeEventListener('abort', abort) } + shared.readers.add(reader) + signal?.addEventListener('abort', abort, { once: true }) + }) + this.drain() + return promise + } + + /** Cancel all readers and wait until actual reads, conversions, and scratch cleanup finish. */ + async dispose(): Promise { + this.disposed = true + for (const job of this.jobs) this.fail(job, this.unavailable()) + this.ready.clear() + this.aliases.clear() + this.cachedBytes = 0 + await Promise.allSettled(this.tasks) + } + + private busy(): DocumentRenderError { return new DocumentRenderError('busy', 'The document renderer has reached its admission limit.') } + private unavailable(): DocumentRenderError { return new DocumentRenderError('unavailable', 'The document renderer is unavailable.') } + private copy(result: DocumentRenderResult): DocumentRenderResult { + return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] } + } + private release(reader: Reader): void { + reader.job.readers.delete(reader) + if (reader.job.state === 'queued') { + reader.job.priority = [...reader.job.readers].some(other => other.priority === 'foreground') ? 'foreground' : 'background' + } + if (![...reader.job.readers].some(other => other.source === reader.source)) { + reader.job.sources.delete(reader.source) + if (this.sources.get(reader.source) === reader.job) this.sources.delete(reader.source) + } + reader.cleanup() + this.readers-- + } + private cancel(job: Job): void { + job.controller.abort() + if (job.state === 'queued') { + this.queue.splice(this.queue.indexOf(job), 1) + job.state = 'finished' + this.jobs.delete(job) + } + } + private fail(job: Job, error: unknown): void { + for (const reader of job.readers) { this.release(reader); reader.reject(error) } + this.cancel(job) + } + private reservation(job: Job): number { return Math.max(1, job.request.source.bytes ?? this.config.maxInputBytes) } + + private drain(): void { + while (!this.disposed && this.running < this.config.maxConcurrentConversions) { + const eligible = (job: Job): boolean => this.sourceBytes + this.reservation(job) <= this.config.maxSourceBytes + && (job.priority === 'foreground' || this.background < Math.min(this.config.maxBackgroundConversions, Math.max(1, this.config.maxConcurrentConversions - 1))) + const foregroundWaiting = this.queue.some(item => item.priority === 'foreground') + const job = this.queue.find(item => (!foregroundWaiting || item.priority === 'foreground') && eligible(item)) + if (job === undefined) return + this.queue.splice(this.queue.indexOf(job), 1) + job.state = 'running' + const background = job.priority === 'background' + const reserved = this.reservation(job) + this.running++ + if (background) this.background++ + this.sourceBytes += reserved + const task = this.execute(job, reserved).catch((error: unknown) => { this.fail(job, error) }).finally(() => { + this.running-- + if (background) this.background-- + this.sourceBytes -= reserved + job.state = 'finished' + this.jobs.delete(job) + this.tasks.delete(task) + this.drain() + }) + this.tasks.add(task) + } + } + + private async execute(job: Job, reserved: number): Promise { + const signal = job.controller.signal + signal.throwIfAborted() + const input = await job.request.source.read(signal, reserved) + signal.throwIfAborted() + if (input.version !== job.request.source.version) throw new DocumentRenderError('source-changed', 'The source changed while waiting for conversion.') + if (input.bytes.byteLength > reserved) throw new DocumentRenderError('input-too-large', 'The source exceeds its reserved read capacity.') + const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex') + const key = DocumentRenderKey(`${this.generation}:${digest}`) + const cached = this.ready.get(key) + if (cached !== undefined) { + this.ready.delete(key) + this.ready.set(key, cached) + this.finish(job, cached) + return + } + const existing = this.digests.get(key) + if (existing !== undefined && !existing.controller.signal.aborted) { + if (job.priority === 'foreground') existing.priority = 'foreground' + for (const reader of job.readers) { reader.job = existing; existing.readers.add(reader) } + job.readers.clear() + for (const source of job.sources) { existing.sources.add(source); this.sources.set(source, existing) } + job.sources.clear() + return + } + this.digests.set(key, job) + try { + const converted = await this.convert(input.bytes, job.request.extension, signal) + signal.throwIfAborted() + const result = { ...converted, cacheKey: key, generation: this.generation } + this.retain(result) + this.finish(job, result) + } finally { if (this.digests.get(key) === job) this.digests.delete(key) } + } + + private finish(job: Job, result: DocumentRenderResult): void { + for (const source of job.sources) this.sources.delete(source) + if (this.ready.has(result.cacheKey)) { + for (const source of job.sources) { + this.aliases.delete(source) + this.aliases.set(source, result.cacheKey) + while (this.aliases.size > this.config.maxSourceEntries) this.aliases.delete(this.aliases.keys().next().value as string) + } + } + for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) } + } + private retain(result: DocumentRenderResult): void { + if (result.pdf.byteLength > this.config.maxCachedBytes) return + while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) { + const [key, oldest] = this.ready.entries().next().value as [DocumentRenderKey, DocumentRenderResult] + this.ready.delete(key) + this.cachedBytes -= oldest.pdf.byteLength + for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source) + } + this.ready.set(result.cacheKey, result) + this.cachedBytes += result.pdf.byteLength + } +} diff --git a/packages/document/document-render-libreoffice/tests/output.spec.ts b/packages/document/document-render-libreoffice/tests/output.spec.ts new file mode 100644 index 0000000000..ad897ef5f0 --- /dev/null +++ b/packages/document/document-render-libreoffice/tests/output.spec.ts @@ -0,0 +1,64 @@ +/** PDF output checks use opened-file metadata and enforce byte bounds during reads. */ +import { appendFile, mkdtemp, rm, stat, writeFile, type FileHandle } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { readPdf } from '../src/output.ts' + +const io = vi.hoisted(() => ({ opened: undefined as ((file: FileHandle) => Promise) | undefined })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters): ReturnType { + const file = await actual.open(...args) + await io.opened?.(file) + return file + }, + } +}) + +afterEach(() => { io.opened = undefined; vi.restoreAllMocks() }) + +const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') + +async function output(): Promise<{ directory: string; path: string; handles: FileHandle[] }> { + const directory = await mkdtemp(join(tmpdir(), 'dsh-render-output-')) + const handles: FileHandle[] = [] + onTestFinished(async () => { + try { await Promise.all(handles.map(file => file.close())) } + finally { await rm(directory, { recursive: true, force: true }) } + }) + const path = join(directory, 'preview.pdf') + await writeFile(path, pdf) + return { directory, path, handles } +} + +it('closes an opened output that is not a regular file despite earlier path metadata', async () => { + const { directory, path, handles } = await output() + const directoryInfo = await stat(directory) + io.opened = async (file) => { + handles.push(file) + // Directory handles cannot be opened on Windows; fstat supplies the same rejection input. + vi.spyOn(file, 'stat').mockResolvedValueOnce(directoryInfo) + } + await expect(readPdf(path, pdf.length, new AbortController().signal)).rejects.toMatchObject({ code: 'invalid-output' }) + expect(handles).toHaveLength(1) + expect(handles[0]!.fd).toBe(-1) +}) + +it('rejects output that grows past the limit after opened-file metadata is read', async () => { + const { path, handles } = await output() + io.opened = async (file) => { + handles.push(file) + const inspect = file.stat.bind(file) + vi.spyOn(file, 'stat').mockImplementationOnce(async () => { + const info = await inspect() + await appendFile(path, 'extra output') + return info + }) + } + await expect(readPdf(path, pdf.length, new AbortController().signal)).rejects.toMatchObject({ code: 'output-too-large' }) + expect(handles).toHaveLength(1) + expect(handles[0]!.fd).toBe(-1) +}) diff --git a/packages/document/document-render-libreoffice/tests/provider.spec.ts b/packages/document/document-render-libreoffice/tests/provider.spec.ts new file mode 100644 index 0000000000..aa36e95d2c --- /dev/null +++ b/packages/document/document-render-libreoffice/tests/provider.spec.ts @@ -0,0 +1,269 @@ +/** Disk output, resource bounds, and cancellation around the external kit. */ +import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { Context } from '@deepseek-ai/cordis' +import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import LibreOfficeRenderer, { Config } from '../src/index.ts' + +const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) +vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) + +const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') +const input = new Uint8Array([80, 75, 3, 4]) +const request: DocumentRenderRequest = { extension: 'docx', priority: 'foreground', source: { + key: DocumentSourceKey('source'), version: 'v1', bytes: input.length, + read: async () => ({ bytes: input, version: 'v1' }), +} } +function distinct(index: number): DocumentRenderRequest { + return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`), + read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } } +} +let ctx: Context +let render: ReturnType> +let dispose: ReturnType> + +beforeEach(() => { + ctx = new Context() + render = vi.fn().mockImplementation(async ({ outputPath }) => { + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: ['Missing Serif'] } + }) + dispose = vi.fn().mockResolvedValue(undefined) + kit.create.mockReset().mockImplementation(async () => ({ backend: 'native', render, dispose })) +}) +afterEach(async () => { await ctx.fiber.dispose() }) + +async function mount(config: Partial = {}): Promise { + await ctx.plugin(LibreOfficeRenderer, config) + return ctx.documentRender as LibreOfficeRenderer +} + +it('preserves the kit font defaults when font configuration is omitted', async () => { + const renderer = await mount() + await renderer.render(request) + expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontDirectories') + expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontFallbacks') + expect(Config({ fontDirectories: [] }).fontDirectories).toEqual([]) + expect(Config({ fontFallbacks: [] }).fontFallbacks).toEqual([]) + expect(() => Config({ fontDirectories: [''] })).toThrow() +}) + +it.each([ + [[]], + [['sans-serif']], + [['sans-serif', '']], + [['sans-serif', ' \t\n ']], +])('rejects invalid font preference groups %j before creating a converter', (...fontFallbacks) => { + expect(() => Config({ fontFallbacks })).toThrow() + expect(kit.create).not.toHaveBeenCalled() +}) + +it('passes configured limits to the kit, reuses the converter, and removes all scratch files', async () => { + const config = Config({ maxConcurrentConversions: 1, maxInputBytes: 4, maxOutputBytes: pdf.length, + maxImageResolution: 144, maxArchiveEntries: 32, maxUncompressedBytes: 4096, + timeoutMs: 321, fontDirectories: [], fontFallbacks: [['sans-serif', 'Arial'], ['serif', 'Times New Roman']], + maxFontFiles: 12, maxFontFileBytes: 4096, maxLoadedFontBytes: 8192 }) + const renderer = await mount(config) + let scratch = '' + render.mockImplementation(async ({ inputPath, outputPath }) => { + scratch = dirname(inputPath) + expect(await readFile(inputPath)).toEqual(Buffer.from(input)) + if (process.platform !== 'win32') { + expect((await stat(scratch)).mode & 0o777).toBe(0o700) + expect((await stat(inputPath)).mode & 0o777).toBe(0o600) + } + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: ['Missing Serif'] } + }) + const result = await renderer.render(request) + expect(result).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) + await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) + await renderer.render(request) + const { maxConcurrentConversions: _count, maxQueuedJobs: _queued, maxReaders: _readers, maxSourceBytes: _source, + maxBackgroundConversions: _background, maxCachedEntries: _entries, maxCachedBytes: _cached, + maxSourceEntries: _aliases, ...options } = config + expect(kit.create).toHaveBeenCalledExactlyOnceWith(options) + await ctx.fiber.dispose() + expect(dispose).toHaveBeenCalledOnce() + expect(result.pdf).toEqual(Uint8Array.from(pdf)) +}) + +it('refuses an oversized input before allocating a converter', async () => { + const renderer = await mount({ maxInputBytes: 3 }) + await expect(renderer.render(request)).rejects.toMatchObject({ code: 'input-too-large' }) + expect(kit.create).not.toHaveBeenCalled() +}) + +it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const)('rejects %s kit output and removes its directory', async (kind) => { + const renderer = await mount({ maxOutputBytes: pdf.length }) + let scratch = '' + render.mockImplementation(async ({ inputPath, outputPath }) => { + scratch = dirname(inputPath) + if (kind === 'directory') await mkdir(outputPath) + else if (kind === 'not-pdf') await writeFile(outputPath, 'engine diagnostic') + else if (kind === 'incomplete') await writeFile(outputPath, '%PDF-1.7\n') + else if (kind === 'too-large') await writeFile(outputPath, Buffer.concat([pdf, pdf])) + return { backend: 'wasm', missingFonts: [] } + }) + await expect(renderer.render(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' }) + await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('retries initialization after a failed kit factory and reports unclassified errors', async () => { + const renderer = await mount() + kit.create.mockRejectedValueOnce(Object.assign(new Error('absent asset'), { code: 'unavailable' })) + await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) + expect(await renderer.render(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) + expect(kit.create).toHaveBeenCalledTimes(2) + render.mockRejectedValueOnce(new Error('unexpected engine failure')) + await expect(renderer.render(distinct(9))).rejects.toMatchObject({ code: 'failed' }) +}) + +it.each([ + 'input-too-large', 'output-too-large', 'invalid-document', 'unsupported-format', 'invalid-output', 'timeout', 'unavailable', +] as const)('preserves the kit %s failure for the document consumer', async (code) => { + const renderer = await mount() + const cause = Object.assign(new Error('conversion failed'), { code }) + render.mockRejectedValueOnce(cause) + await expect(renderer.render(request)).rejects.toMatchObject({ code, cause }) +}) + +it('cancels a queued caller without starting or stopping another conversion', async () => { + const renderer = await mount({ maxConcurrentConversions: 1 }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + render.mockImplementationOnce(async ({ outputPath }, signal) => { + entered.resolve(signal!) + await release.promise + await writeFile(outputPath, pdf) + return { backend: 'wasm', missingFonts: [] } + }) + const first = renderer.render(request) + try { + const activeSignal = await entered.promise + const controller = new AbortController() + const second = renderer.render(request, controller.signal) + const rejected = expect(second).rejects.toMatchObject({ name: 'AbortError' }) + await Promise.resolve(undefined) + controller.abort() + await rejected + expect(render).toHaveBeenCalledOnce() + expect(activeSignal.aborted).toBe(false) + } finally { release.resolve(undefined); await first } + expect(await renderer.render(request)).toHaveProperty('pdf') +}) + +it('bounds active converters and resumes queued work when a slot becomes free', async () => { + const renderer = await mount({ maxConcurrentConversions: 2 }) + const both = Promise.withResolvers() + const release = Promise.withResolvers() + let calls = 0 + render.mockImplementation(async ({ outputPath }) => { + if (++calls === 2) both.resolve(undefined) + await release.promise + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: [] } + }) + const work = [renderer.render(distinct(1)), renderer.render(distinct(2)), renderer.render(distinct(3))] + try { + await both.promise + expect(kit.create).toHaveBeenCalledTimes(2) + expect(render).toHaveBeenCalledTimes(2) + } finally { release.resolve(undefined); await Promise.all(work) } + expect(render).toHaveBeenCalledTimes(3) + expect(kit.create).toHaveBeenCalledTimes(2) +}) + +it.each(['caller', 'provider'] as const)('joins late kit completion and scratch cleanup after %s cancellation', async (owner) => { + const renderer = await mount() + const entered = Promise.withResolvers<{ signal: AbortSignal; scratch: string }>() + const release = Promise.withResolvers() + render.mockImplementation(async ({ inputPath, outputPath }, signal) => { + entered.resolve({ signal: signal!, scratch: dirname(inputPath) }) + await release.promise + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: [] } + }) + const caller = new AbortController() + const work = renderer.render(request, caller.signal) + const rejected = expect(work).rejects.toMatchObject(owner === 'caller' ? { name: 'AbortError' } : { code: 'unavailable' }) + const { signal, scratch } = await entered.promise + let settled = false + const observed = work.then(() => { settled = true }, () => { settled = true }) + let closing: Promise | undefined + try { + if (owner === 'caller') caller.abort() + else closing = ctx.fiber.dispose() + await vi.waitFor(() => { expect(signal.aborted).toBe(true) }) + await rejected + await observed + expect(settled).toBe(true) + await access(scratch) + } finally { release.resolve(undefined); await rejected; await observed; await closing; await ctx.fiber.dispose() } + await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) + if (owner === 'provider') expect(dispose).toHaveBeenCalledOnce() +}) + +it('joins initialization during disposal and never starts a render after cancellation', async () => { + const renderer = await mount() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + kit.create.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise }) + const work = renderer.render(request) + const rejected = expect(work).rejects.toMatchObject({ code: 'unavailable' }) + await entered.promise + const closing = ctx.fiber.dispose() + release.resolve({ backend: 'wasm', render, dispose }) + await rejected + await closing + expect(render).not.toHaveBeenCalled() + expect(dispose).toHaveBeenCalledOnce() + await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) +}) + +it('rejects caller cancellation before allocating any conversion resources', async () => { + const renderer = await mount() + const reason = new Error('caller cancelled') + await expect(renderer.render(request, AbortSignal.abort(reason))).rejects.toBe(reason) + expect(kit.create).not.toHaveBeenCalled() +}) + +it('rejects relative font directories during provider configuration', async () => { + expect(() => new LibreOfficeRenderer(ctx, Config({ fontDirectories: ['relative/fonts'] }))) + .toThrow('fontDirectories must contain absolute paths') +}) + +it('rejects source capacity below one permitted input before allocating a converter', () => { + expect(() => new LibreOfficeRenderer(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) + .toThrow('maxSourceBytes must be at least maxInputBytes') + expect(kit.create).not.toHaveBeenCalled() +}) + +it('joins every converter disposal before reporting an engine cleanup failure', async () => { + const renderer = await mount({ maxConcurrentConversions: 2 }) + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + const cleanupEntered = Promise.withResolvers() + const cleanupRelease = Promise.withResolvers() + let conversions = 0 + render.mockImplementation(async ({ outputPath }) => { + if (++conversions === 2) entered.resolve(undefined) + await released.promise + await writeFile(outputPath, pdf) + return { backend: 'native', missingFonts: [] } + }) + const failed = new Error('engine cleanup failed') + kit.create.mockReset() + .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { throw failed } }) + .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } }) + const work = [renderer.render(distinct(1)), renderer.render(distinct(2))] + try { await entered.promise } finally { released.resolve(undefined); await Promise.all(work) } + let disposed = false + const closing = ctx.fiber.dispose().then(() => { disposed = true }) + try { + await cleanupEntered.promise + expect(disposed).toBe(false) + } finally { cleanupRelease.resolve(undefined); await closing } +}) diff --git a/packages/document/document-render-libreoffice/tests/queue.spec.ts b/packages/document/document-render-libreoffice/tests/queue.spec.ts new file mode 100644 index 0000000000..9ac47223d5 --- /dev/null +++ b/packages/document/document-render-libreoffice/tests/queue.spec.ts @@ -0,0 +1,563 @@ +/** Controlled source and engine completions exercise admission and shared content ownership. */ +import { DocumentSourceKey, DocumentRendererGeneration, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { expect, it, onTestFinished, vi } from 'vitest' +import { RenderQueue } from '../src/queue.ts' +import { Config } from '../src/index.ts' + +const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] } +function source(key: string, byte = 1, priority: DocumentRenderRequest['priority'] = 'foreground') { + const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) + const request: DocumentRenderRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } + return { read, request } +} +function harness(config: Partial = {}) { + const convert = vi.fn[2]>().mockResolvedValue(output) + const queue = new RenderQueue(Config(config), DocumentRendererGeneration('test'), convert) + onTestFinished(() => queue.dispose()) + return { queue, convert } +} + +it('shares authorized source metadata before reading and content across distinct source paths', async () => { + const h = harness() + const entered = Promise.withResolvers() + const complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const a = source('a'), b = source('b') + const first = h.queue.read(a.request) + await entered.promise + const same = h.queue.read(a.request) + const equalContent = h.queue.read(b.request) + try { + await vi.waitFor(() => { expect(b.read).toHaveBeenCalledOnce() }) + expect(a.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledOnce() + } finally { complete.resolve(output) } + const results = await Promise.all([first, same, equalContent]) + expect(new Set(results.map(result => result.cacheKey)).size).toBe(1) + results[0].pdf[0] = 0 + results[0].missingFonts.length = 0 + expect(results[1].pdf).toEqual(output.pdf) + expect((await h.queue.read(b.request)).missingFonts).toEqual(['Font']) + expect(b.read).toHaveBeenCalledOnce() +}) + +it('continues queued work when the first queued reader cancels immediately after admission', async () => { + const h = harness({ maxConcurrentConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('active', 1).request) + await entered.promise + const cancelled = new AbortController() + const second = source('cancelled', 2), third = source('later', 3) + second.read.mockImplementation(async () => { + cancelled.abort() + return { bytes: new Uint8Array([2]), version: 'v1' } + }) + const queued = h.queue.read(second.request, cancelled.signal) + const rejected = expect(queued).rejects.toMatchObject({ name: 'AbortError' }) + const later = h.queue.read(third.request) + try { + expect(second.read).not.toHaveBeenCalled() + expect(third.read).not.toHaveBeenCalled() + } finally { complete.resolve(output) } + await first + await rejected + await expect(later).resolves.toMatchObject(output) + expect(second.read).toHaveBeenCalledOnce() + expect(third.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledTimes(2) +}) + +it('promotes a queued prewarm when a foreground reader joins, without another source read', async () => { + const h = harness({ maxConcurrentConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('active', 1).request) + await entered.promise + const order: string[] = [] + const a = source('a', 2, 'background'), b = source('b', 3, 'background') + a.read.mockImplementation(async () => { order.push('a'); return { bytes: new Uint8Array([2]), version: 'v1' } }) + b.read.mockImplementation(async () => { order.push('b'); return { bytes: new Uint8Array([3]), version: 'v1' } }) + const backgroundA = h.queue.read(a.request), backgroundB = h.queue.read(b.request) + const foreground = h.queue.read({ ...b.request, priority: 'foreground' }) + expect(a.read).not.toHaveBeenCalled() + expect(b.read).not.toHaveBeenCalled() + complete.resolve(output) + await Promise.all([first, backgroundA, backgroundB, foreground]) + expect(order).toEqual(['b', 'a']) + expect(b.read).toHaveBeenCalledOnce() +}) + +it('evicts queued speculation for foreground work and bounds the metadata queue', async () => { + const h = harness({ maxConcurrentConversions: 1, maxQueuedJobs: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('active').request) + await entered.promise + const prewarm = source('prewarm', 2, 'background') + const discarded = expect(h.queue.read(prewarm.request)).rejects.toMatchObject({ code: 'busy' }) + const overflow = source('background-overflow', 4, 'background') + await expect(h.queue.read(overflow.request)).rejects.toMatchObject({ code: 'busy' }) + expect(overflow.read).not.toHaveBeenCalled() + const requested = source('requested', 3) + const next = h.queue.read(requested.request) + await expect(h.queue.read(source('overflow', 4).request)).rejects.toMatchObject({ code: 'busy' }) + await discarded + expect(prewarm.read).not.toHaveBeenCalled() + expect(requested.read).not.toHaveBeenCalled() + complete.resolve(output) + await Promise.all([first, next]) + expect(requested.read).toHaveBeenCalledOnce() +}) + +it('refuses disabled prewarming before reading a source while admitting foreground work', async () => { + const h = harness({ maxBackgroundConversions: 0 }) + const a = source('a', 1, 'background') + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' }) + expect(a.read).not.toHaveBeenCalled() + expect(h.convert).not.toHaveBeenCalled() + await h.queue.read({ ...a.request, priority: 'foreground' }) + expect(a.read).toHaveBeenCalledOnce() +}) + +it('refuses disabled prewarming joins and releases cancelled foreground capacity', async () => { + const h = harness({ maxBackgroundConversions: 0, maxConcurrentConversions: 1, maxQueuedJobs: 1, maxReaders: 5 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const running = source('running', 1), queued = source('queued', 2), later = source('later', 3) + const foreground = new AbortController(), background = new AbortController() + const pending: Promise[] = [h.queue.read(running.request)] + const rejected = vi.fn() + try { + await entered.promise + pending.push(h.queue.read(queued.request, foreground.signal).catch((error: unknown) => error)) + for (const item of [running, queued]) { + pending.push(h.queue.read({ ...item.request, priority: 'background' }, background.signal).catch(rejected)) + } + await expect.poll(() => rejected.mock.calls.length).toBe(2) + expect(rejected.mock.calls).toEqual([[expect.objectContaining({ code: 'busy' })], [expect.objectContaining({ code: 'busy' })]]) + foreground.abort() + const next = h.queue.read(later.request) + pending.push(next) + expect(queued.read).not.toHaveBeenCalled() + expect(later.read).not.toHaveBeenCalled() + complete.resolve(output) + await expect(next).resolves.toMatchObject(output) + await expect(h.queue.read({ ...running.request, priority: 'background' })).resolves.toMatchObject(output) + expect(running.read).toHaveBeenCalledOnce() + expect(later.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { + foreground.abort() + background.abort() + complete.resolve(output) + await Promise.allSettled(pending) + } +}) + +it('reserves foreground capacity while limiting concurrent prewarming', async () => { + const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('first', 1, 'background').request) + await entered.promise + const second = source('second', 2, 'background') + const background = h.queue.read(second.request) + const foreground = source('foreground', 3) + await h.queue.read(foreground.request) + expect(second.read).not.toHaveBeenCalled() + complete.resolve(output) + await Promise.all([first, background]) +}) + +it('holds reserved source capacity until canceled engine work actually settles', async () => { + const h = harness({ maxConcurrentConversions: 2, maxInputBytes: 1, maxSourceBytes: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise }) + const caller = new AbortController() + const firstSource = source('first'), nextSource = source('next', 2) + const first = expect(h.queue.read(firstSource.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await entered.promise + caller.abort() + await first + expect(signal.aborted).toBe(true) + const next = h.queue.read(nextSource.request) + expect(nextSource.read).not.toHaveBeenCalled() + complete.resolve(output) + await next + await h.queue.read(firstSource.request) + expect(firstSource.read).toHaveBeenCalledTimes(2) +}) + +it('bounds shared readers and leaves other readers alive after cancellation', async () => { + const h = harness({ maxReaders: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise }) + const a = source('a'), caller = new AbortController() + const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await entered.promise + const second = h.queue.read(a.request) + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' }) + caller.abort() + await first + expect(signal.aborted).toBe(false) + complete.resolve(output) + await second +}) + +it('keeps digest-shared readers alive when the original source reader leaves', async () => { + const h = harness() + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce((_bytes, _extension, signal: AbortSignal) => { entered.resolve(signal); return complete.promise }) + const a = source('a'), b = source('b'), caller = new AbortController() + const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + const signal = await entered.promise + const second = h.queue.read(b.request) + await vi.waitFor(() => { expect(b.read).toHaveBeenCalledOnce() }) + caller.abort() + await first + expect(signal.aborted).toBe(false) + complete.resolve(output) + await second + expect(h.convert).toHaveBeenCalledOnce() +}) + +it('rejects source version changes and oversized reads without converting or retaining them', async () => { + const h = harness({ maxInputBytes: 2, maxSourceBytes: 2 }) + const a = source('a') + a.read.mockResolvedValueOnce({ bytes: new Uint8Array([1]), version: 'v2' }) + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'source-changed' }) + a.read.mockResolvedValueOnce({ bytes: new Uint8Array([1, 2]), version: 'v1' }) + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'input-too-large' }) + expect(h.convert).not.toHaveBeenCalled() + await h.queue.read(a.request) + expect(a.read).toHaveBeenCalledTimes(3) + expect(a.read.mock.calls[0]![1]).toBe(1) +}) + +it('reserves the input cap for an unknown stat size and rejects known overflow before reading', async () => { + const h = harness({ maxInputBytes: 2, maxSourceBytes: 2 }) + const a = source('a') + await expect(h.queue.read({ ...a.request, source: { ...a.request.source, bytes: 3 } })).rejects.toMatchObject({ code: 'input-too-large' }) + expect(a.read).not.toHaveBeenCalled() + const { bytes: _bytes, ...unknown } = a.request.source + await h.queue.read({ ...a.request, source: unknown }) + expect(a.read.mock.calls[0]![1]).toBe(2) +}) + +it('evicts least-recently-used content and bounds pre-read aliases independently', async () => { + const h = harness({ maxCachedEntries: 2, maxCachedBytes: 8, maxSourceEntries: 1 }) + const a = source('a', 1), alias = source('alias', 1), b = source('b', 2), c = source('c', 3) + await h.queue.read(a.request) + await h.queue.read(alias.request) + await h.queue.read(a.request) + expect(a.read).toHaveBeenCalledTimes(2) + expect(h.convert).toHaveBeenCalledOnce() + await h.queue.read(b.request) + await h.queue.read(a.request) + await h.queue.read(c.request) + await h.queue.read(b.request) + expect(h.convert).toHaveBeenCalledTimes(4) +}) + +it('does not retain failures or PDFs above the retention budget', async () => { + const h = harness({ maxCachedBytes: 1 }) + const a = source('a') + h.convert.mockRejectedValueOnce(new Error('engine failure')) + await expect(h.queue.read(a.request)).rejects.toThrow('engine failure') + await h.queue.read(a.request) + await h.queue.read(a.request) + expect(h.convert).toHaveBeenCalledTimes(3) +}) + +it('evicts every alias of the least-recently-used PDF while preserving other aliases', async () => { + const h = harness({ maxCachedEntries: 2 }) + const a = source('a', 1), alias = source('alias', 1), b = source('b', 2), c = source('c', 3) + await h.queue.read(a.request) + await h.queue.read(alias.request) + await h.queue.read(b.request) + await h.queue.read(c.request) + await h.queue.read(b.request) + expect(b.read).toHaveBeenCalledOnce() + await h.queue.read(a.request) + await h.queue.read(alias.request) + expect(a.read).toHaveBeenCalledTimes(2) + expect(alias.read).toHaveBeenCalledTimes(2) + expect(h.convert).toHaveBeenCalledTimes(4) +}) + +it('keeps a synchronous abort replacement shareable after the old conversion settles late', async () => { + const h = harness({ maxConcurrentConversions: 2 }) + const firstEntered = Promise.withResolvers(), firstComplete = Promise.withResolvers() + const replacementEntered = Promise.withResolvers(), replacementComplete = Promise.withResolvers() + const replacementSpawned = Promise.withResolvers<{ work: ReturnType }>() + const a = source('a'), caller = new AbortController() + const pending: Promise[] = [] + h.convert.mockImplementationOnce((_bytes, _extension, signal) => { + signal.addEventListener('abort', () => { + const work = h.queue.read(a.request) + pending.push(work) + replacementSpawned.resolve({ work }) + }, { once: true }) + firstEntered.resolve(undefined) + return firstComplete.promise + }) + .mockImplementationOnce(() => { replacementEntered.resolve(undefined); return replacementComplete.promise }) + const first = expect(h.queue.read(a.request, caller.signal)).rejects.toMatchObject({ cause: 'reader left' }) + pending.push(first) + try { + await firstEntered.promise + caller.abort('reader left') + await first + const { work: replacement } = await replacementSpawned.promise + await replacementEntered.promise + const next = h.queue.read(source('next', 2).request) + pending.push(next) + firstComplete.resolve(output) + await next + const sameSource = h.queue.read(a.request) + const alias = source('alias', 1, 'background') + const sameContent = h.queue.read(alias.request) + pending.push(sameSource, sameContent) + await vi.waitFor(() => { expect(alias.read).toHaveBeenCalledOnce() }) + expect(a.read).toHaveBeenCalledTimes(2) + expect(h.convert).toHaveBeenCalledTimes(3) + replacementComplete.resolve(output) + const results = await Promise.all([replacement, sameSource, sameContent]) + expect(new Set(results.map(result => result.cacheKey)).size).toBe(1) + } finally { + firstComplete.resolve(output) + replacementComplete.resolve(output) + await Promise.allSettled(pending) + } +}) + +it('separates Office extensions and renderer generations in content identity', async () => { + const h = harness(), a = source('a') + const first = await h.queue.read(a.request) + const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' }) + const other = new RenderQueue(Config({}), DocumentRendererGeneration('replacement'), h.convert) + onTestFinished(() => other.dispose()) + const replacement = await other.read(a.request) + expect(first.cacheKey).not.toBe(otherFormat.cacheKey) + expect(first.cacheKey).not.toBe(replacement.cacheKey) +}) + +it('reserves the final reader admission for foreground interest', async () => { + const h = harness({ maxReaders: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const a = source('a', 1, 'background') + const background = h.queue.read(a.request) + await entered.promise + await expect(h.queue.read(a.request)).rejects.toMatchObject({ code: 'busy' }) + const foreground = h.queue.read({ ...a.request, priority: 'foreground' }) + complete.resolve(output) + await Promise.all([background, foreground]) + expect(a.read).toHaveBeenCalledOnce() +}) + +it('serves ready PDFs while every outstanding reader slot is occupied', async () => { + const h = harness({ maxReaders: 1 }), cached = source('cached') + await h.queue.read(cached.request) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const pending = h.queue.read(source('active', 2).request) + try { + await entered.promise + await expect(h.queue.read(cached.request)).resolves.toMatchObject(output) + expect(cached.read).toHaveBeenCalledOnce() + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { complete.resolve(output); await pending } +}) + +it('keeps background reservations behind foreground work waiting for source capacity', async () => { + const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 }) + const running = source('running', 1), foreground = source('foreground', 2), background = source('background', 3, 'background') + const entered = Promise.withResolvers(), foregroundEntered = Promise.withResolvers() + const complete = Promise.withResolvers(), foregroundComplete = Promise.withResolvers() + const order: number[] = [] + h.convert.mockImplementation((bytes) => { + order.push(bytes[0]!) + if (bytes[0] === 1) { entered.resolve(undefined); return complete.promise } + if (bytes[0] === 2) { foregroundEntered.resolve(undefined); return foregroundComplete.promise } + return Promise.resolve(output) + }) + const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } }) + await entered.promise + const next = h.queue.read({ ...foreground.request, source: { ...foreground.request.source, bytes: 48 } }) + const speculative = h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } }) + try { + expect(foreground.read).not.toHaveBeenCalled() + expect(background.read).not.toHaveBeenCalled() + complete.resolve(output) + await foregroundEntered.promise + expect(order).toEqual([1, 2]) + } finally { + complete.resolve(output) + foregroundComplete.resolve(output) + await Promise.allSettled([first, next, speculative]) + } + expect(order).toEqual([1, 2, 3]) +}) + +it('starts eligible background work when its queued foreground blocker is cancelled', async () => { + const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 }) + const running = source('running', 1), foreground = source('foreground', 2), background = source('background', 3, 'background') + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } }) + const caller = new AbortController() + let speculative: Promise | undefined + try { + await entered.promise + const cancelled = expect(h.queue.read({ ...foreground.request, source: { ...foreground.request.source, bytes: 48 } }, caller.signal)) + .rejects.toMatchObject({ name: 'AbortError' }) + speculative = h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } }) + expect(background.read).not.toHaveBeenCalled() + caller.abort() + await cancelled + expect(foreground.read).not.toHaveBeenCalled() + expect(background.read).toHaveBeenCalledOnce() + await speculative + expect(h.convert).toHaveBeenCalledTimes(2) + } finally { caller.abort(); complete.resolve(output); await Promise.allSettled([first, speculative]) } +}) + +it('starts eligible background work after a queued promotion loses its final foreground reader', async () => { + const h = harness({ maxInputBytes: 50, maxSourceBytes: 60, maxConcurrentConversions: 2 }) + const running = source('running', 1), promoted = source('promoted', 2, 'background'), background = source('background', 3, 'background') + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read({ ...running.request, source: { ...running.request.source, bytes: 15 } }) + const callers = [new AbortController(), new AbortController()] + const pending: Promise[] = [first] + try { + await entered.promise + const request = { ...promoted.request, source: { ...promoted.request.source, bytes: 48 } } + pending.push(h.queue.read(request)) + const cancelled = callers.map(caller => expect(h.queue.read({ ...request, priority: 'foreground' }, caller.signal)) + .rejects.toMatchObject({ name: 'AbortError' })) + pending.push(...cancelled) + pending.push(h.queue.read({ ...background.request, source: { ...background.request.source, bytes: 45 } })) + expect(background.read).not.toHaveBeenCalled() + callers[0]!.abort() + await cancelled[0] + expect(background.read).not.toHaveBeenCalled() + callers[1]!.abort() + await cancelled[1] + expect(promoted.read).not.toHaveBeenCalled() + expect(background.read).toHaveBeenCalledOnce() + } finally { + for (const caller of callers) caller.abort() + complete.resolve(output) + await Promise.allSettled(pending) + } +}) + +it('counts a demoted queued prewarm against background concurrency when it starts', async () => { + const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 }) + const complete = Promise.withResolvers(), prewarmComplete = Promise.withResolvers() + const prewarmEntered = Promise.withResolvers() + h.convert.mockImplementation((bytes) => { + if (bytes[0] === 1 || bytes[0] === 2) return complete.promise + if (bytes[0] === 3) { prewarmEntered.resolve(undefined); return prewarmComplete.promise } + return Promise.resolve(output) + }) + const first = h.queue.read(source('first', 1).request), second = h.queue.read(source('second', 2).request) + const prewarm = source('promoted', 3, 'background'), other = source('other', 4, 'background') + const warming = h.queue.read(prewarm.request), waiting = h.queue.read(other.request) + const caller = new AbortController() + const cancelled = expect(h.queue.read({ ...prewarm.request, priority: 'foreground' }, caller.signal)) + .rejects.toMatchObject({ name: 'AbortError' }) + try { + expect(prewarm.read).not.toHaveBeenCalled() + caller.abort() + await cancelled + complete.resolve(output) + await prewarmEntered.promise + await h.queue.read(source('foreground', 5).request) + expect(other.read).not.toHaveBeenCalled() + } finally { + caller.abort() + complete.resolve(output) + prewarmComplete.resolve(output) + await Promise.allSettled([first, second, warming, waiting, cancelled]) + } +}) + +it('releases source indexes when the last reader for each digest-shared path leaves', async () => { + const h = harness({ maxReaders: 2, maxSourceEntries: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('retained').request) + await entered.promise + // Both retained indexes must stay bounded while the underlying converter is blocked. + const indexes = h.queue as unknown as { sources: Map }> } + const retained = [...indexes.sources.values()][0]! + try { + for (let index = 0; index < 5; index++) { + const alias = source(`cancelled-${index}`), caller = new AbortController() + const joined = h.queue.read(alias.request, caller.signal) + const rejected = expect(joined).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => { expect(retained.sources.size).toBe(2) }) + caller.abort() + await rejected + expect(indexes.sources.size).toBe(1) + expect(retained.sources.size).toBe(1) + } + expect(h.convert).toHaveBeenCalledOnce() + } finally { complete.resolve(output); await first } +}) + +it('rereads a cancelled source on reopen while sharing its running or completed conversion', async () => { + const h = harness({ maxReaders: 2, maxSourceEntries: 2 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const first = h.queue.read(source('retained').request), reopened = source('reopened') + try { + await entered.promise + for (const count of [1, 2]) { + const caller = new AbortController() + const cancelled = expect(h.queue.read(reopened.request, caller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + try { await vi.waitFor(() => { expect(reopened.read).toHaveBeenCalledTimes(count) }) } + finally { caller.abort(); await cancelled } + } + complete.resolve(output) + await first + await h.queue.read(reopened.request) + expect(reopened.read).toHaveBeenCalledTimes(3) + await h.queue.read(reopened.request) + expect(reopened.read).toHaveBeenCalledTimes(3) + expect(h.convert).toHaveBeenCalledOnce() + } finally { complete.resolve(output); await first } +}) + +it('keeps speculative admission occupied after promotion while allowing other foreground work', async () => { + const h = harness({ maxConcurrentConversions: 2, maxBackgroundConversions: 1 }) + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const original = source('original', 1, 'background'), waiting = source('waiting', 2, 'background') + const first = h.queue.read(original.request) + await entered.promise + const promoted = h.queue.read({ ...original.request, priority: 'foreground' }) + const speculative = h.queue.read(waiting.request) + try { + await h.queue.read(source('requested', 3).request) + expect(waiting.read).not.toHaveBeenCalled() + } finally { complete.resolve(output); await Promise.all([first, promoted, speculative]) } + expect(waiting.read).toHaveBeenCalledOnce() +}) + +it('classifies provider disposal independently from caller cancellation', async () => { + const h = harness() + const entered = Promise.withResolvers(), complete = Promise.withResolvers() + h.convert.mockImplementationOnce(() => { entered.resolve(undefined); return complete.promise }) + const pending = expect(h.queue.read(source('active').request)).rejects.toMatchObject({ code: 'unavailable' }) + await entered.promise + const closing = h.queue.dispose() + try { + await pending + await expect(h.queue.read(source('later').request)).rejects.toMatchObject({ code: 'unavailable' }) + } finally { complete.resolve(output); await closing } +}) diff --git a/packages/document/document-render-libreoffice/tsconfig.json b/packages/document/document-render-libreoffice/tsconfig.json new file mode 100644 index 0000000000..8e81caaa59 --- /dev/null +++ b/packages/document/document-render-libreoffice/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../document-render" + } + ] +} diff --git a/packages/document/document-render/README.i18n.yaml b/packages/document/document-render/README.i18n.yaml new file mode 100644 index 0000000000..1fff641553 --- /dev/null +++ b/packages/document/document-render/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/document/document-render/README.md +README.md: ead6c62b25dcca326ad362af4e28a6431472f55c +README.zh.md: 45138e306a367833e0acade97b7d13f46fdcefa8 diff --git a/packages/document/document-render/README.md b/packages/document/document-render/README.md new file mode 100644 index 0000000000..ead6c62b25 --- /dev/null +++ b/packages/document/document-render/README.md @@ -0,0 +1,75 @@ +--- +description: "Authorized Office input and complete PDF output for Host conversion providers." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render + +English | [中文](README.zh.md) + +## Summary + +Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes to a complete PDF. Callers receive PDF bytes and, for OOXML inputs, missing-font families without changing the original file or adding model context. + +## 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 [LibreOffice provider](../document-render-libreoffice/README.md) to supply `ctx.documentRender`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `render()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentRenderError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentRenderError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation. + +
+ +----- + + +## Further Exploration + +- [Document rendering](../../../docs/subsystems/document-render.md) — composition and ownership. +- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. + +----- + + +## Model Experience + +None, as this package converts bytes without model-facing tools, messages, or Session events. + +#### KV Cache effect + +None; conversion does not construct or modify model requests. + +## Known Limitations and Deferred Work + + + +- The service accepts only the six listed Office formats; source authorization belongs to consumers, while shared conversion reuse belongs to the provider. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/document/document-render/README.zh.md b/packages/document/document-render/README.zh.md new file mode 100644 index 0000000000..45138e306a --- /dev/null +++ b/packages/document/document-render/README.zh.md @@ -0,0 +1,75 @@ +--- +description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF 输出。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-document-render + +[English](README.md) | 中文 + +## 概述 + +将已授权的 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 字节转换为完整 PDF。调用方获得 PDF 字节;OOXML 输入还提供缺失字体名称,不修改原文件或添加模型上下文。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延后工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +挂载 [LibreOffice 提供方](../document-render-libreoffice/README.zh.md)以提供 `ctx.documentRender`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `render()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 + +----- + + +## 理解实现 + +
+实现内部细节 — 点击展开 + +提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和渲染 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentRenderError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentRenderError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。 + +
+ +----- + + +## 延伸阅读 + +- [文档渲染](../../../docs/subsystems/document-render.zh.md) — 组合和所有权。 +- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 + +----- + + +## 模型体验 + +无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。 + +#### KV Cache effect + +无;转换不会构造或修改模型请求。 + +## 已知限制与延后工作 + + + +- 服务仅接受上述六种 Office 格式;源文件授权由消费者负责,共享转换复用由提供方负责。 + + +### 开发备注 + +
+维护者工作上下文 — 点击展开 + +无。 + +
diff --git a/packages/document/document-render/package.json b/packages/document/document-render/package.json new file mode 100644 index 0000000000..741ffca7db --- /dev/null +++ b/packages/document/document-render/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-document-render", + "description": "Authorized Office bytes to PDF rendering service", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/document/document-render" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^" + } +} diff --git a/packages/document/document-render/src/index.ts b/packages/document/document-render/src/index.ts new file mode 100644 index 0000000000..49d121945e --- /dev/null +++ b/packages/document/document-render/src/index.ts @@ -0,0 +1,44 @@ +/** Host document rendering: authorized Office bytes become a complete PDF. */ +import { Context, Service } from '@deepseek-ai/cordis' +import type { DocumentRenderErrorCode, DocumentRenderRequest, DocumentRenderResult, DocumentRendererGeneration } from './types.ts' + +export * from './types.ts' + +/** Classified conversion failure; engine details stay in the cause. */ +export class DocumentRenderError extends Error { + /** + * @param code - category suitable for a conversion consumer. + * @param message - diagnostic explaining the failed conversion. + * @param options - underlying engine or filesystem failure. + */ + constructor(readonly code: DocumentRenderErrorCode, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'DocumentRenderError' + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** One Host renderer for already-authorized Office bytes. */ + documentRender: DocumentRenderer + } +} + +/** Load one provider subclass per context; consumers own source authorization. */ +export abstract class DocumentRenderer extends Service { + constructor(ctx: Context) { super(ctx, 'documentRender') } + + /** Changes whenever engine, font, or rendering configuration is replaced. */ + abstract readonly generation: DocumentRendererGeneration + + /** + * Convert Office bytes without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ + abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +} + +export default DocumentRenderer diff --git a/packages/document/document-render/src/types.ts b/packages/document/document-render/src/types.ts new file mode 100644 index 0000000000..ce23ae3cb6 --- /dev/null +++ b/packages/document/document-render/src/types.ts @@ -0,0 +1,71 @@ +/** Authorized Office input and complete PDF output, independent of the conversion engine. */ +import { brandString, type Branded } from '@deepseek-ai/dsh-brand' + +/** Binary Office and Office Open XML formats supported by document preview. */ +export type DocumentExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' + +/** Authorized execution scope and canonical source path, encoded by the consumer. */ +export type DocumentSourceKey = Branded<'DocumentSourceKey'> +/** + * Label an authorized source locator for pre-read deduplication. + * @param key - unambiguous encoding of authorization scope, execution world, and canonical path. + * @returns branded source locator; source authorization remains the caller's responsibility. + */ +export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString(key) } + +/** One provider lifetime, including its engine, rendering settings, and font configuration. */ +export type DocumentRendererGeneration = Branded<'DocumentRendererGeneration'> +/** + * Label a provider lifetime. + * @param value - unique generation created by the provider. + * @returns branded renderer generation. + */ +export function DocumentRendererGeneration(value: string): DocumentRendererGeneration { + return brandString(value) +} + +/** Provider generation and source-content digest; consumers must not parse it. */ +export type DocumentRenderKey = Branded<'DocumentRenderKey'> +/** + * Label a renderer-owned content identity. + * @param value - generation and content identity created by the provider. + * @returns branded conversion identity. + */ +export function DocumentRenderKey(value: string): DocumentRenderKey { return brandString(value) } + +/** Foreground previews and explicit QA precede speculative background conversion. */ +export type DocumentRenderPriority = 'foreground' | 'background' + +/** Source authorization and metadata lookup must finish before submitting a request. */ +export interface DocumentRenderRequest { + readonly extension: DocumentExtension + readonly priority: DocumentRenderPriority + readonly source: { + readonly key: DocumentSourceKey + readonly version: string + /** Authorized stat size; omission reserves the provider's entire input limit. */ + readonly bytes?: number + /** + * Read only after provider admission; do not capture already-buffered input in queued production requests. + * @param signal - shared conversion lifetime, independent of an individual reader. + * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte. + * @returns owned bytes and the actual read version; a changed version rejects conversion. + */ + read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }> + } +} + +/** Successful conversion; failed and interrupted conversions reject instead. */ +export interface DocumentRenderResult { + /** Caller-owned complete PDF, valid after provider disposal. */ + readonly pdf: Uint8Array + /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ + readonly missingFonts: string[] + readonly cacheKey: DocumentRenderKey + readonly generation: DocumentRendererGeneration +} + +/** Failures a conversion consumer can present without exposing engine diagnostics. */ +export type DocumentRenderErrorCode = + | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' + | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-render/tsconfig.json b/packages/document/document-render/tsconfig.json new file mode 100644 index 0000000000..85055ef176 --- /dev/null +++ b/packages/document/document-render/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + } + ] +} diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 1b815a3e4a..1e6f283ded 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: 724e47a004fac83fe0f19a588faf0c227d843232 -README.zh.md: 4e88b3cf10f98a423268c60b69269d2550d2c1f9 +README.md: 6a6911824c8c71b3deb5037d1ecf629771211a26 +README.zh.md: c0ef4275507725bc6f1178ab2568c4fb6127479a diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 724e47a004..6a6911824c 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -55,6 +55,7 @@ None; this package neither assembles nor sends a provider request. - **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here. - **Host package commands are unavailable**: `execa` reports an explicit worker-host refusal; the preview cannot run pnpm or install native dependencies. - **PTC Node programs are unavailable**: the process shim exposes `/dsh/bin/node` as its executable identity so the provider can activate, but the Worker has neither a Node executable nor `stripTypeScriptTypes`. Program execution fails before launching a child. +- **Office conversion requires a Node Host**: the LibreOffice kit replacement rejects converter creation with `unavailable`. The browser image excludes the kit and its engine dependencies; the Office preview reports that conversion is unavailable. - **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop. - **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-system/landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation. - **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 4e88b3cf10..c0ef427550 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -55,6 +55,7 @@ kind: "package-library" - **`node:dns/promises`、`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。 - **宿主包管理命令不可用**:`execa` 明确报告 worker host 不支持该调用;预览无法运行 pnpm 或安装原生依赖。 - **PTC Node 程序不可用**:process shim 用 `/dsh/bin/node` 表示可执行文件身份,使 provider 能够激活,但 Worker 既没有 Node 可执行文件,也没有 `stripTypeScriptTypes`。程序执行会在启动子进程前失败。 +- **Office 转换需要 Node Host**:LibreOffice kit 的替代模块以 `unavailable` 拒绝创建转换器。浏览器镜像排除 kit 及其引擎依赖;Office 预览提示转换不可用。 - **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent`、`ref()` 和 `unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。 - **Worker confinement 是 VFS 边界,不是内核 Landlock**:`read-only` 和 `workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-system/landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。 - **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。 diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 05e9b74339..67ebb786cf 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -65,6 +65,7 @@ export const MODULE_PROXIES: Record = { 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', // External npm replacements, named after the package each stands in for. + '@deepseek-ai/libreoffice-kit': './node/external_packages/libreoffice-kit.ts', '@deepseek-ai/node-addon-system/flock': './node/external_packages/node-addon-system-flock.ts', 'koffi': './node/external_packages/koffi.ts', 'sharp': './node/external_packages/sharp.ts', diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index 649129e300..aa21754b4e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -47,6 +47,7 @@ import * as nodeVm from './builtin_modules/mock/vm.ts' import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts' import * as systemFlock from './external_packages/node-addon-system-flock.ts' import * as koffi from './external_packages/koffi.ts' +import * as libreofficeKit from './external_packages/libreoffice-kit.ts' import * as nodePty from './external_packages/node-pty.ts' import * as execa from './external_packages/execa.ts' import * as piAi from './external_packages/pi-ai.ts' @@ -87,6 +88,7 @@ const BUILTINS: Record = { /** Exact package or subpath specifiers served by worker stubs and fakes. */ const EXTERNALS: Record = { + '@deepseek-ai/libreoffice-kit': () => libreofficeKit, '@deepseek-ai/node-addon-system/flock': () => systemFlock, 'koffi': () => koffi, 'sharp': () => sharp, diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts new file mode 100644 index 0000000000..a871627f38 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts @@ -0,0 +1,12 @@ +/** Office conversion requires a Node Host; the browser preview carries no engine assets. */ +import { notAvailableError } from '../notImplementedFail.ts' + +/** + * Refuse converter creation with the kit's unavailable error code. + * @returns A rejected promise consumed by the Host document-render provider. + */ +export function createConverter(): Promise { + return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), { + code: 'unavailable', + })) +} diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts index 106aab2c6f..81f78e6506 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts @@ -9,6 +9,7 @@ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [ '@earendil-works/pi-ai', '@vscode/ripgrep', + '@deepseek-ai/libreoffice-kit', '@deepseek-ai/node-addon-system/flock', 'koffi', 'node-pty', diff --git a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts index 24d84c79a9..e6101f17b3 100644 --- a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts @@ -21,6 +21,7 @@ import * as stream from '../../src/node/builtin_modules/implemented/stream.ts' import * as vm from '../../src/node/builtin_modules/mock/vm.ts' import * as workerThreads from '../../src/node/builtin_modules/mock/worker_threads.ts' import * as nodePty from '../../src/node/external_packages/node-pty.ts' +import * as libreofficeKit from '../../src/node/external_packages/libreoffice-kit.ts' import * as execa from '../../src/node/external_packages/execa.ts' import * as piAi from '../../src/node/external_packages/pi-ai.ts' import * as ripgrep from '../../src/node/external_packages/ripgrep.ts' @@ -118,6 +119,14 @@ describe('constructible-but-inert fakes', () => { }) describe('replaced external packages', () => { + it('reports Office conversion as unavailable without creating a Node worker', async () => { + quiet() + await expect(libreofficeKit.createConverter()).rejects.toMatchObject({ + code: 'unavailable', + message: 'web-preview: @deepseek-ai/libreoffice-kit.createConverter is not available in the worker host', + }) + }) + it('lists the packages the loader serves from the bundle', () => { expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('chokidar') expect(REPLACED_EXTERNAL_PACKAGES).not.toContain('@deepseek-ai/node-addon-system') diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 38490167e7..6afe5966a7 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -874,6 +874,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'documentRender', + summary: 'Load one provider subclass per context; consumers own source authorization.', + description: 'Load one provider subclass per context; consumers own source authorization.', + methods: [ + { + signature: 'abstract readonly generation: DocumentRendererGeneration', + description: 'Changes whenever engine, font, or rendering configuration is replaced.', + parameters: [], + }, + { + signature: 'abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise', + description: 'Convert Office bytes without modifying the source or writing Session events.', + parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], + returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', + throws: ['{DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], + }, + ], + }, { key: 'fileReferences', summary: 'Host capability for cancellable file-reference discovery.', @@ -3227,6 +3246,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', }, + { + signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', + description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.', + parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.', + }, { signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise', description: 'Read a complete file relative to another file\'s directory, including outside the workspace.', @@ -4448,6 +4473,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryRegistrationHandle', declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', }, + { + name: 'DocumentExtension', + declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', + }, + { + name: 'DocumentRendererGeneration', + declaration: 'export type DocumentRendererGeneration = Branded<\'DocumentRendererGeneration\'>;', + }, + { + name: 'DocumentRenderKey', + declaration: 'export type DocumentRenderKey = Branded<\'DocumentRenderKey\'>;', + }, + { + name: 'DocumentRenderPriority', + declaration: 'export type DocumentRenderPriority = \'foreground\' | \'background\';', + }, + { + name: 'DocumentRenderRequest', + declaration: 'export interface DocumentRenderRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentRenderPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', + }, + { + name: 'DocumentRenderResult', + declaration: 'export interface DocumentRenderResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentRenderKey;\n readonly generation: DocumentRendererGeneration;\n}', + }, + { + name: 'DocumentSourceKey', + declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;', + }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15278dadd3..10bb66ca6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2223,6 +2223,12 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner + '@deepseek-ai/dsh-document-render': + specifier: workspace:^ + version: link:../../document/document-render + '@deepseek-ai/dsh-document-render-libreoffice': + specifier: workspace:^ + version: link:../../document/document-render-libreoffice '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -5954,6 +5960,38 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/document/document-render: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + + packages/document/document-render-libreoffice: + dependencies: + '@deepseek-ai/libreoffice-kit': + specifier: 0.0.1 + version: 0.0.1 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + 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-document-render': + specifier: workspace:^ + version: link:../document-render + packages/experimental/agent-team: dependencies: '@deepseek-ai/dsh-brand': @@ -8081,7 +8119,7 @@ importers: version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.85.1 - version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3) + version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -13255,6 +13293,39 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1': + resolution: {integrity: sha512-3vrYUIZihcb+F9qjjHdWNSxW83fLihrBV63qaw5kxW1Q7hbZPPMDsjlRJ2rX6VUi06By4nJH9YLKZ73iGN5iEQ==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [darwin] + + '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1': + resolution: {integrity: sha512-xMMXJkjggd9c1g85oLNHiJl1xgowe0EG60ib8up3Pz0qFD9HORMu0/eP1eB19E8jLRwqF9Sb0TTrcqp68aVcyw==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [darwin] + + '@deepseek-ai/libreoffice-kit-wasm@0.0.1': + resolution: {integrity: sha512-VHGr+MvRQMCXxVvdYZ8qJnVXOMjUivntvNHCDBog+cCjgEfWjUl+/0rfvF2auPjnWUzqvjKMEdqbemhc1zuqFg==} + engines: {node: '>=22.19.0'} + os: [linux] + + '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1': + resolution: {integrity: sha512-9G7YxXHEFaVn5iFM6IaHusWuXAd3cEprwTYSA1OsYMUREliHzdcmJEqcEyePDpX1y39KNKWQgowYeSpFbfrbgg==} + engines: {node: '>=22.19.0'} + cpu: [arm64] + os: [win32] + + '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1': + resolution: {integrity: sha512-+DPPT5V6rfwWfMf7scfgzxwNBQiuePdY4dmVvWb4QAINgquOXUAyWVNZaThWNCO8baRn1l4fb3L5sdbLWib3zQ==} + engines: {node: '>=22.19.0'} + cpu: [x64] + os: [win32] + + '@deepseek-ai/libreoffice-kit@0.0.1': + resolution: {integrity: sha512-e4JZqohz5TEVfI3sfUK/dHRLjqlQep96CmqNM93Rm7LY9PuECiJakIX2BdHi5OkmbK9Mo47SiW44HNTTiwW1yw==} + engines: {node: '>=22.19.0'} + '@docsearch/css@3.8.2': resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} @@ -15331,6 +15402,9 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -16210,6 +16284,9 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browserslist@4.28.6: resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -16351,6 +16428,10 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -16715,6 +16796,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -17058,6 +17142,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -17099,6 +17186,9 @@ packages: debug: optional: true + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -18357,6 +18447,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -18684,6 +18777,9 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -19063,6 +19159,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} @@ -19248,6 +19347,12 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -20414,6 +20519,33 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-wasm@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1': + optional: true + + '@deepseek-ai/libreoffice-kit@0.0.1': + dependencies: + fflate: 0.8.2 + fontkit: 2.0.4 + saxes: 6.0.0 + optionalDependencies: + '@deepseek-ai/libreoffice-kit-darwin-arm64': 0.0.1 + '@deepseek-ai/libreoffice-kit-darwin-x64': 0.0.1 + '@deepseek-ai/libreoffice-kit-wasm': 0.0.1 + '@deepseek-ai/libreoffice-kit-win32-arm64': 0.0.1 + '@deepseek-ai/libreoffice-kit-win32-x64': 0.0.1 + '@docsearch/css@3.8.2': {} '@docsearch/js@3.8.2(@algolia/client-search@5.55.2)(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': @@ -20442,14 +20574,14 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 @@ -22033,6 +22165,10 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -23028,6 +23164,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browserslist@4.28.6: dependencies: baseline-browser-mapping: 2.10.43 @@ -23069,7 +23209,7 @@ snapshots: cross-spawn: 7.0.6 debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 js-yaml: 4.3.1 sanitize-filename: 1.6.4 @@ -23173,6 +23313,8 @@ snapshots: dependencies: mimic-response: 1.0.1 + clone@2.1.2: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -23533,6 +23675,8 @@ snapshots: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + diff@8.0.4: {} diff@9.0.0: {} @@ -24030,6 +24174,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} + fflate@0.8.3: {} figures@6.1.0: @@ -24073,6 +24219,18 @@ snapshots: follow-redirects@1.16.0: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.23 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -24366,7 +24524,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@9.4.0): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@9.4.0) @@ -25541,6 +25699,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@0.2.9: {} + pako@1.0.11: {} parse-ms@4.0.0: {} @@ -25859,6 +26019,8 @@ snapshots: dependencies: lowercase-keys: 2.0.0 + restructure@3.0.2: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -26383,6 +26545,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-inflate@1.0.3: {} + tiny-typed-emitter@2.1.0: {} tinybench@2.9.0: {} @@ -26526,6 +26690,16 @@ snapshots: undici@8.10.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} union@0.5.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0f9020b1b2..05802dbbd7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,6 +52,13 @@ allowBuilds: msgpackr-extract: false minimumReleaseAgeExclude: + # Office engines and their API are qualified and released together by the kit repository. + - '@deepseek-ai/libreoffice-kit@0.0.1' + - '@deepseek-ai/libreoffice-kit-darwin-arm64@0.0.1' + - '@deepseek-ai/libreoffice-kit-darwin-x64@0.0.1' + - '@deepseek-ai/libreoffice-kit-win32-arm64@0.0.1' + - '@deepseek-ai/libreoffice-kit-win32-x64@0.0.1' + - '@deepseek-ai/libreoffice-kit-wasm@0.0.1' # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.85.1' diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index dd883770cc..631a6680d5 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 8ab226427f89d2746d6da13bcd8bd254731f2460 -README.zh.md: 23a042006c38425a83347322fa63f0fafb319d0d +README.md: ba1814ce00dea9dd35bcfbb330801942b86af155 +README.zh.md: 792c7e06a29be987a25887882b6df976138b194f diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 8ab226427f..ba1814ce00 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -10,6 +10,8 @@ The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Py Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. +Each target also requires `-office/`, where the stem excludes `.exe`. This directory contains the complete installed Office packages and their dependencies, preserving engine resources, manifests, licenses, source inventories, and helper permissions. Copy this directory together with the executable. A missing target engine fails the sidecar build with its npm package name and target platform/architecture. + Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. @@ -27,6 +29,8 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr `dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Because operating-system symlinks cannot enter that filesystem, packaged launches maintain small real ESM proxy packages under `$DSH_HOME/profiles/node_modules`. Each proxy mirrors explicit runtime exports, records the original package identity, and re-exports the virtual module URL. Built-in rows and external plugin peers therefore share one Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. +The Python bootstrap resolves the Office kit from its adjacent directory so native helpers and URL Workers use real filesystem paths. The kit owns engine selection and validation; the Python bootstrap adds no runtime download or compilation. + External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. ## Build and distribution @@ -35,4 +39,4 @@ Production deployment permits unused workspace patches for packages outside the From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. -The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: native on macOS/Windows and WASM on Linux. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 23a042006c..792c7e06a2 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -10,6 +10,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。wheel 包标签必须与载荷严格匹配;不发布 Windows arm64 wheel 包。 +每个目标还要求 `-office/`,其中 stem 不含 `.exe`。该目录包含完整的已安装 Office 包及其依赖,保留引擎资源、清单、许可证、源码清单与辅助程序权限。复制可执行文件时必须一并复制此目录。缺少目标引擎会使 sidecar 构建失败,错误会指出其 npm 包名与目标平台/架构。 + 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。 两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest(元数据清单)定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 @@ -27,6 +29,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python `dsh` 在显式指定的主目录下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。操作系统符号链接无法进入该文件系统,因此打包运行会在 `$DSH_HOME/profiles/node_modules` 下维护小型真实 ESM 代理包。每个代理复现运行时的显式导出项、记录原包身份,并重新导出虚拟模块 URL。因此,内置配置项与外部插件 peer 会共享同一个 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 +Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。kit 负责引擎选择与校验;Python bootstrap 不增加运行时下载或编译。 + 外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 ## 构建与分发 @@ -35,4 +39,4 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 -已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 +已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:macOS/Windows 使用原生引擎,Linux 使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 22d0457d86..d642eec5e2 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -73,6 +73,8 @@ class RuntimeBuildHook(BuildHookInterface): f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" ) expected_executable = matches[0][1] + target = next(name for name, value in _PLATFORMS.items() if value[0] == platform_tag) + engine = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] @@ -84,6 +86,8 @@ class RuntimeBuildHook(BuildHookInterface): ) if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") + office = runtime_dir / f"{expected_executable.removesuffix('.exe')}-office" + expected_files.append(office.name) expected_files.sort() found_files = [path.name for path in runtime_files] if found_files != expected_files: @@ -91,6 +95,13 @@ class RuntimeBuildHook(BuildHookInterface): f"runtime wheel {platform_tag} payload must be {expected_files}; found {found_files}" ) for executable in runtime_files: + if executable == office: + for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): + if not (office / "node_modules" / required).is_file(): + raise RuntimeError(f"runtime Office dependency is missing: {office / required}") + continue + if not executable.is_file(): + raise RuntimeError(f"runtime executable is not a file: {executable}") if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0: raise RuntimeError(f"runtime executable is not executable: {executable}") build_data["pure_python"] = False diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index 4005314aa0..4e1c58fd2d 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -22,7 +22,7 @@ dsh = "deepseek_harness_runtime:main" # Include the injected dsh executable and sidecars; exclude the dev-only node closure. [tool.hatch.build] -artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*"] +artifacts = ["src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*", "src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**"] exclude = ["src/deepseek_harness_runtime/runtime/node"] [tool.hatch.build.targets.wheel] diff --git a/python/sdk-runtime/runtime-bootstrap.mjs b/python/sdk-runtime/runtime-bootstrap.mjs index 6ca1c1da82..3db857c0a7 100644 --- a/python/sdk-runtime/runtime-bootstrap.mjs +++ b/python/sdk-runtime/runtime-bootstrap.mjs @@ -1,6 +1,19 @@ #!/usr/bin/env node /** Private entry owned by the Python single-file runtime packaging. */ -import { fileURLToPath } from 'node:url' +import { registerHooks } from 'node:module' +import { isSea } from 'node:sea' +import { fileURLToPath, pathToFileURL } from 'node:url' + +if (isSea()) { + // Office spawns executable helpers and URL workers; its complete package tree must be real files. + const parentURL = pathToFileURL(`${process.execPath.replace(/\.exe$/i, '')}-office/package.json`).href + registerHooks({ + resolve(specifier, context, nextResolve) { + const office = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json' + return nextResolve(specifier, office ? { ...context, parentURL } : context) + }, + }) +} const selectorName = 'DSH_SUBPROCESS_RUNNER' const selection = process.env[selectorName] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 5235ff4d00..546bab36c7 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -57,7 +57,7 @@ def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. Raises FileNotFoundError when the platform is unsupported, the executable - has not been placed into this package, the required ripgrep sidecar is + has not been placed into this package, the ripgrep or Office sidecar is missing, or the required macOS spawn helper is missing; the message names the acquisition routes (acquisition strategy is deliberately separate from this lookup interface, so an on-demand download can replace it without @@ -88,6 +88,14 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + _EXE_ACQUISITION_HINT ) + office = path.with_name(f"{path.name.removesuffix('.exe')}-office") + engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") + for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): + if not (office / "node_modules" / required).is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 9c87c5fe82..b3a9e259ef 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import runpy +import zipfile from pathlib import Path from types import SimpleNamespace @@ -119,6 +120,10 @@ def test_stage_runtime_copies_platform_payload( spawn_helper.write_bytes(b"helper") spawn_helper.chmod(0o755) expected[spawn_helper.name] = b"helper" + office = executable.parent / build_python_release.office_sidecar_name(executable.name) + office_asset = office / "node_modules" / "@deepseek-ai" / "libreoffice-kit-wasm" / "assets" / "soffice.data" + office_asset.parent.mkdir(parents=True) + office_asset.write_bytes(b"office data") destination = tmp_path / "staging" build_python_release.stage_runtime(destination, "1.2.3", executable, executable.name) @@ -127,7 +132,9 @@ def test_stage_runtime_copies_platform_payload( assert { path.name: path.read_bytes() for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*") + if path.is_file() } == expected + assert (runtime_dir / office.name / office_asset.relative_to(office)).read_bytes() == b"office data" pyproject = (destination / "pyproject.toml").read_text() assert 'license = "MIT"' in pyproject assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject @@ -152,3 +159,42 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> executable, "deepseek-harness-sdk-runtime-win-x64.exe", ) + + +@pytest.mark.parametrize("platform_tag,selected", [ + ("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"), + ("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"), +]) +@pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"]) +def test_office_wheel_requires_only_target_engine( + tmp_path: Path, platform_tag: str, selected: str, invalid: str | None, +) -> None: + root = "runtime-office/node_modules" + wheel = tmp_path / "office.zip" + native = selected != "wasm" + if invalid == "helper-mode" and (not native or platform_tag == "win_amd64"): + pytest.skip("executable mode applies to POSIX native helpers") + engine = ({"kind": "native", "executable": "bin/helper"} if native else + {"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"}) + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", "{}") + base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}" + if invalid != "missing-engine": + archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine})) + for field in (("executable",) if native else ("loader", "wasm", "data", "metadata")): + if invalid == "asset" and field == ("executable" if native else "data"): + continue + asset = zipfile.ZipInfo(f"{base}/{engine[field]}") + asset.external_attr = (0o644 if invalid == "helper-mode" else 0o755) << 16 + archive.writestr(asset, b"payload") + if invalid == "foreign-engine": + foreign = "wasm" if native else "darwin-arm64" + archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit-{foreign}/prebuilds.json", "{}") + with zipfile.ZipFile(wheel) as archive: + if invalid is None: + build_python_release.verify_office_payload(archive, root, platform_tag) + else: + message = {"asset": "asset is missing", "missing-engine": "dependency is missing", + "foreign-engine": "Unexpected Office engine", "helper-mode": "executable bit"}[invalid] + with pytest.raises(RuntimeError, match=message): + build_python_release.verify_office_payload(archive, root, platform_tag) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index dd520df78b..0c6fa828a2 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -19,6 +19,17 @@ from deepseek_harness_runtime import ( ) +def _office_sidecar(executable: Path) -> Path: + office = executable.with_name(f"{executable.name.removesuffix('.exe')}-office") + tag = executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe") + engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") + for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): + path = office / "node_modules" / required + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}") + return office + + def test_unknown_explicit_mode_fails_loud() -> None: with pytest.raises(ValueError, match="expected 'exe' or 'node'"): resolve_bundled_launch_args("bogus") @@ -47,6 +58,7 @@ def test_runtime_requires_spawn_helper_only_on_macos( linux = runtime_dir / "deepseek-harness-sdk-runtime-linux-x64" linux.touch() Path(f"{linux}-rg").touch() + _office_sidecar(linux) macos = runtime_dir / "deepseek-harness-sdk-runtime-macos-arm64" macos.touch() Path(f"{macos}-rg").touch() @@ -67,6 +79,7 @@ def test_windows_runtime_uses_exe_payload_and_exe_sidecar( executable = runtime_dir / "deepseek-harness-sdk-runtime-win-x64.exe" executable.touch() (runtime_dir / "deepseek-harness-sdk-runtime-win-x64-rg.exe").touch() + _office_sidecar(executable) monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "win-x64") @@ -103,6 +116,25 @@ def test_runtime_requires_ripgrep_sidecar( runtime.bundled_runtime_path() +def test_runtime_requires_complete_office_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + executable = tmp_path / "runtime" / "deepseek-harness-sdk-runtime-linux-x64" + executable.parent.mkdir() + executable.touch() + Path(f"{executable}-rg").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + + with pytest.raises(FileNotFoundError, match="Office sidecar"): + runtime.bundled_runtime_path() + office = _office_sidecar(executable) + assert runtime.bundled_runtime_path() == executable + (office / "node_modules/@deepseek-ai/libreoffice-kit-wasm/prebuilds.json").unlink() + with pytest.raises(FileNotFoundError, match="Office sidecar"): + runtime.bundled_runtime_path() + + def test_node_mode_runs_the_deployed_dsh_cli( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -195,3 +227,25 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path: assert result.stdout == "stdout-中文\n" assert result.stderr == "stderr-中文\n" assert sentinel.read_text() == "done" + + +@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "win-x64"]) +def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str) -> None: + extension = ".exe" if target.startswith("win-") else "" + executable = tmp_path / "runtime" / f"deepseek-harness-sdk-runtime-{target}{extension}" + executable.parent.mkdir() + executable.touch() + executable.with_name(f"{executable.stem}-rg{extension}").touch() + if target.startswith("macos-"): + Path(f"{executable}-spawn-helper").touch() + office = _office_sidecar(executable) + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: target) + assert runtime.bundled_runtime_path() == executable + engine = next(office.glob("node_modules/@deepseek-ai/libreoffice-kit-*/prebuilds.json")) + engine.unlink() + foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if target.startswith("linux-") else "libreoffice-kit-wasm") / "prebuilds.json" + foreign.parent.mkdir(parents=True, exist_ok=True) + foreign.write_text("{}") + with pytest.raises(FileNotFoundError, match="Office sidecar"): + runtime.bundled_runtime_path() diff --git a/scripts/build-exe-for-python-sdk-office.spec.ts b/scripts/build-exe-for-python-sdk-office.spec.ts new file mode 100644 index 0000000000..de7ae69b5b --- /dev/null +++ b/scripts/build-exe-for-python-sdk-office.spec.ts @@ -0,0 +1,137 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { copyOfficeSidecar } from './build-exe-for-python-sdk-office.ts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))) +}) + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-python-office-')) + temporaryDirectories.push(root) + const staging = join(root, 'staging') + const destination = join(root, 'runtime-office') + async function packageAt(name: string, fields: Record = {}, parent = staging) { + const directory = join(parent, 'node_modules', name) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...fields })) + return directory + } + return { root, staging, destination, packageAt } +} + +it('copies package-owned data, licenses, helper permissions, and nested dependencies', async () => { + const { staging, destination, packageAt } = await fixture() + const entry = await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' }, dependencies: { decoder: '1' } }) + const engine = await packageAt('@deepseek-ai/libreoffice-kit-wasm') + const decoder = await packageAt('decoder', { dependencies: { codec: '1' } }, entry) + await packageAt('codec', {}, decoder) + await packageAt('codec', { version: '2.0.0' }) + const assets = ['assets/soffice.data', 'prebuilds.json', 'licenses/LICENSE', 'bin/helper'] + for (const name of assets) { + const path = join(engine, name) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, name) + } + await chmod(join(engine, 'bin/helper'), 0o755) + + const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }) + + expect(packages).toHaveLength(4) + expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data'), 'utf8')).toBe('assets/soffice.data') + expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/licenses/LICENSE'), 'utf8')).toBe('licenses/LICENSE') + expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/node_modules/decoder/node_modules/codec/package.json'), 'utf8')).toContain('codec') + await expect(stat(join(destination, 'node_modules/codec'))).rejects.toMatchObject({ code: 'ENOENT' }) + if (process.platform !== 'win32') expect((await stat(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/bin/helper'))).mode & 0o111).toBe(0o111) +}) + +it('copies installed target optionals and leaves other platforms and absent optionals out', async () => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { + optionalDependencies: { native: '1', foreign: '1', absent: '1' }, + }) + await packageAt('@deepseek-ai/libreoffice-kit-wasm') + await packageAt('native', { os: ['linux'], cpu: ['x64'] }) + await packageAt('foreign', { os: ['darwin'], cpu: ['arm64'] }) + + const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }) + + expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ + 'node_modules/@deepseek-ai/libreoffice-kit', + 'node_modules/@deepseek-ai/libreoffice-kit-wasm', + 'node_modules/native', + ]) +}) + +it('rejects a missing required dependency before producing a sidecar', async () => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'dsh-missing-office-fixture': '1' } }) + + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow('dsh-missing-office-fixture required by @deepseek-ai/libreoffice-kit is missing') + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('rejects an incomplete installed optional instead of omitting it', async () => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { broken: '1' } }) + await mkdir(join(staging, 'node_modules/broken'), { recursive: true }) + + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('rejects an ancestor dependency outside the deployed closure', async () => { + const { root, staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'ancestor-office-fixture': '1' } }) + await packageAt('ancestor-office-fixture', {}, root) + + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow('outside the deployed closure') +}) + +it('rejects an optional WASM engine resolved outside the deployed closure', async () => { + const { root, staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) + const ambient = await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow(`Python Office dependency is outside the deployed closure: ${ambient}`) + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([ + ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'arm64'], ['win32', 'x64'], ['linux', 'x64'], +])('copies only the %s/%s engine even when other engines are staged', async (platform, arch) => { + const { staging, destination, packageAt } = await fixture() + const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm'] + const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`) + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) }) + for (const name of names) await packageAt(name) + const expected = `@deepseek-ai/libreoffice-kit-${platform === 'linux' ? 'wasm' : `${platform}-${arch}`}` + const packages = await copyOfficeSidecar(staging, destination, { platform, arch }) + expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ + 'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`, + ]) +}) + +it.each(['darwin', 'win32'])('%s requires its native package even when WASM is staged', async (platform) => { + const { staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) + await packageAt('@deepseek-ai/libreoffice-kit-wasm') + await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' })) + .rejects.toThrow(`Python Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`) + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('requires a staged Linux WASM engine even when an ancestor has one', async () => { + const { root, staging, destination, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit') + await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) + await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + .rejects.toThrow('Python Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.') + await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/scripts/build-exe-for-python-sdk-office.ts b/scripts/build-exe-for-python-sdk-office.ts new file mode 100644 index 0000000000..50385ed0f5 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-office.ts @@ -0,0 +1,94 @@ +/** Keep LibreOffice workers, prebuilt engines, and their dependencies on the real filesystem. */ +import { existsSync } from 'node:fs' +import { cp, mkdir, readFile, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { isAbsolute, join, relative, sep } from 'node:path' + +/** pkg applies these exclusions to dependency `files` as well as root asset globs. */ +export const OFFICE_ASSET_IGNORES = [ + '**/node_modules/@deepseek-ai/libreoffice-kit/**', + '**/node_modules/@deepseek-ai/libreoffice-kit-*/**', +] + +interface PackageManifest { + name: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record + os?: string[] + cpu?: string[] +} + +/** + * Copy the installed Office dependency tree without changing package contents or executable modes. + * Harness sidecars require the target native engine on macOS/Windows and WASM on Linux. + * Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail. + * @param staging - Symlink-free deployed Node closure. + * @param destination - Target-specific Office directory beside the executable; replaced when present. + * @param target - Node platform and CPU of the executable. + * @returns Relative package directories included in the sidecar. + */ +export async function copyOfficeSidecar( + staging: string, + destination: string, + target: { platform: string; arch: string }, +): Promise { + const engineName = `@deepseek-ai/libreoffice-kit-${target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`}` + const packages = new Set() + + async function visit(packageDirectory: string): Promise { + if (packages.has(packageDirectory)) return + const relativeDirectory = relative(staging, packageDirectory) + if (isAbsolute(relativeDirectory) || relativeDirectory === '..' || relativeDirectory.startsWith(`..${sep}`)) { + throw new Error(`Python Office dependency is outside the deployed closure: ${packageDirectory}`) + } + const manifestPath = join(packageDirectory, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as PackageManifest + packages.add(packageDirectory) + const require = createRequire(manifestPath) + const dependencies = new Set([ + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ]) + for (const name of dependencies) { + if (name.startsWith('@deepseek-ai/libreoffice-kit-') && name !== engineName) continue + const optional = manifest.optionalDependencies?.[name] !== undefined + || manifest.peerDependenciesMeta?.[name]?.optional === true + const dependencyDirectory = (require.resolve.paths(name) ?? []) + .map(directory => join(directory, name)) + .find(directory => existsSync(directory)) + if (dependencyDirectory === undefined) { + if (optional) continue + throw new Error(`Python Office dependency ${name} required by ${manifest.name} is missing.`) + } + if (optional) { + const dependency = JSON.parse(await readFile(join(dependencyDirectory, 'package.json'), 'utf8')) as PackageManifest + if (!supports(dependency.os, target.platform) || !supports(dependency.cpu, target.arch)) continue + } + await visit(dependencyDirectory) + } + } + + await visit(join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit')) + const engineDirectory = join(staging, 'node_modules', engineName) + if (!existsSync(engineDirectory)) throw new Error(`Python Office engine ${engineName} required for ${target.platform}/${target.arch} is missing.`) + await visit(engineDirectory) + await rm(destination, { recursive: true, force: true }) + await mkdir(destination, { recursive: true }) + const directories = [...packages].sort() + for (const source of directories) { + const nestedModules = join(source, 'node_modules') + await cp(source, join(destination, relative(staging, source)), { + recursive: true, + filter: path => path !== nestedModules && !path.startsWith(nestedModules + sep), + }) + } + return directories.map(directory => relative(staging, directory)) +} + +function supports(values: string[] | undefined, value: string): boolean { + return values === undefined || (!values.includes(`!${value}`) + && (values.every(item => item.startsWith('!')) || values.includes(value) || values.includes('any'))) +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index b615666c31..fc06183c91 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -12,6 +12,7 @@ import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, wri import { basename, dirname, extname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts' +import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts' const root = resolve(import.meta.dirname, '..') @@ -398,7 +399,7 @@ class SingleExeBuild { /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { - const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } + const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: OFFICE_ASSET_IGNORES } } const manifestPath = join(this.staging, 'package.json') if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`) @@ -418,7 +419,7 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. + * @returns the executable, Office directory, ripgrep, and required macOS spawn helper paths. */ async pack(target: Target): Promise { const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) @@ -438,8 +439,16 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } + const office = `${productBase}-office` + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`) + } else { + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform + const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch }) + console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`) + } const ripgrep = await this.copyRipgrepSidecar(target, product) - if (target.platform !== 'macos') return [product, ripgrep] + if (target.platform !== 'macos') return [product, ripgrep, office] const spawnHelper = `${product}-spawn-helper` const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { @@ -448,7 +457,7 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, 0o755) } - return [product, ripgrep, spawnHelper] + return [product, ripgrep, spawnHelper, office] } /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ @@ -538,6 +547,10 @@ class SingleExeBuild { console.log(` ${path}`) continue } + if (statSync(path).isDirectory()) { + console.log(` ${path} (Office dependency directory)`) + continue + } const megabytes = statSync(path).size / (1024 * 1024) console.log(` ${path} (${megabytes.toFixed(1)} MB)`) } @@ -559,7 +572,10 @@ class SingleExeBuild { await mkdir(destDir, { recursive: true }) for (const path of products) { const destination = join(destDir, basename(path)) - await copyFile(path, destination) + if (statSync(path).isDirectory()) { + await rm(destination, { recursive: true, force: true }) + await cp(path, destination, { recursive: true }) + } else await copyFile(path, destination) await chmod(destination, statSync(path).mode & 0o777) console.log(`build-exe-for-python-sdk: synced ${destination}`) } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 085974f921..04500c942b 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -55,6 +55,11 @@ def runtime_filenames(executable_name: str) -> tuple[str, ...]: return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names +def office_sidecar_name(executable_name: str) -> str: + """Return the complete Office dependency directory for one executable.""" + return f"{executable_name.removesuffix('.exe')}-office" + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) @@ -220,6 +225,42 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ source_directory = executable.parent for filename in runtime_filenames(executable_name): shutil.copy2(source_directory / filename, runtime_dir / filename) + office = office_sidecar_name(executable_name) + shutil.copytree(source_directory / office, runtime_dir / office) + + +def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None: + """Check packaged engine assets and native helper executable permissions.""" + names = set(archive.namelist()) + adapter = f"{office_modules}/@deepseek-ai/libreoffice-kit/package.json" + if adapter not in names: + raise RuntimeError("Office dependency is missing: libreoffice-kit") + engines = f"{office_modules}/@deepseek-ai" + target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag) + selected = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") + for required in (f"libreoffice-kit-{selected}/prebuilds.json",): + if f"{engines}/{required}" not in names: + raise RuntimeError(f"Office dependency is missing: {required}") + manifests = ( + name for name in names + if name.startswith(f"{engines}/libreoffice-kit-") + and name.endswith("/prebuilds.json") + and name.count("/") == engines.count("/") + 2 + ) + for manifest_path in manifests: + if manifest_path != f"{engines}/libreoffice-kit-{selected}/prebuilds.json": + raise RuntimeError(f"Unexpected Office engine for {platform_tag}: {manifest_path}") + manifest = json.loads(archive.read(manifest_path)) + engine = manifest["engine"] + native = engine["kind"] == "native" + fields = ("executable",) if native else ("loader", "wasm", "data", "metadata") + for field in fields: + asset = f"{manifest_path.rsplit('/', 1)[0]}/{engine[field]}" + if asset not in names: + raise RuntimeError(f"Office engine asset is missing: {asset}") + mode = archive.getinfo(asset).external_attr >> 16 + if native and platform_tag != "win_amd64" and mode & stat.S_IXUSR == 0: + raise RuntimeError(f"Office helper lost its executable bit: {asset}") def verify_wheel( @@ -253,21 +294,26 @@ def verify_wheel( raise RuntimeError( f"{wheel} has license files {license_files}, expected {expected_license_files}" ) - runtime_files = [ + runtime_payload = [ name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name ] if package == "runtime": assert platform is not None - expected_files = sorted(runtime_filenames(platform[1])) - found_files = sorted(Path(name).name for name in runtime_files) + office = office_sidecar_name(platform[1]) + expected_files = sorted((*runtime_filenames(platform[1]), office)) + found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload}) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") - for runtime_file in runtime_files: + office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules" + verify_office_payload(archive, office_modules, platform[0]) + for runtime_file in runtime_payload: + if "/" in runtime_file.split("/runtime/", 1)[1]: + continue mode = archive.getinfo(runtime_file).external_attr >> 16 if platform[0] != "win_amd64" and mode & stat.S_IXUSR == 0: raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {runtime_file}") - elif runtime_files: - raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") + elif runtime_payload: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime files: {runtime_payload}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}" diff --git a/scripts/fixtures/python-sdk-office.mjs b/scripts/fixtures/python-sdk-office.mjs new file mode 100644 index 0000000000..e7922a2399 --- /dev/null +++ b/scripts/fixtures/python-sdk-office.mjs @@ -0,0 +1,15 @@ +/** Exercise the wheel's external Office package from a shipped dsh profile. */ +import { writeFile } from 'node:fs/promises' +import { createConverter } from '@deepseek-ai/libreoffice-kit' + +export const name = 'python-sdk-office-smoke' + +export async function apply(_ctx, config) { + const converter = await createConverter({ timeoutMs: 120_000 }) + try { + const result = await converter.render({ inputPath: config.input, outputPath: config.output }) + await writeFile(config.result, JSON.stringify({ ...result, moduleUrl: import.meta.resolve('@deepseek-ai/libreoffice-kit') })) + } finally { + await converter.dispose() + } +} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6f88617ebc..07b0dc583c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', + documentRender: 'document-render.md', attachments: 'attachment.md', shell: 'shell.md', shellEnv: 'shell.md', @@ -276,6 +277,10 @@ export const LINK_MAP: Readonly> = { PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', + DocumentRenderRequest: 'document-render.md', + DocumentRenderResult: 'document-render.md', + DocumentRenderPriority: 'document-render.md', + DocumentRendererGeneration: 'document-render.md', Agent: 'core.md', AgentCancelCause: 'core.md', AgentFactory: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a76de96c18..d60798a00d 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -63,6 +63,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service' const GROUP_ORDER = [ 'util', 'attachment', + 'document', 'llm', 'core', 'typert', @@ -149,6 +150,11 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['experimental-computer-use-cua-driver-mcp', 'experimental-computer-use-cua-driver-native'], note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.', }, + { + key: 'documentRender', pkg: 'document-render', title: 'Office to PDF rendering', + mode: 'seam', implementations: ['document-render-libreoffice'], consumers: [], + note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.', + }, { key: 'attachments', pkg: 'attachment', diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 1bfbe99e31..3ab18f9291 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -34,6 +34,8 @@ describe('THIRD_PARTY_NOTICES.md', () => { 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('## 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) }) }) @@ -49,6 +51,27 @@ function workspace(entries: Record): { manifests: Map { + it('limits the LibreOffice exception to its reviewed package identity and MPL terms', () => { + for (const name of [ + '@deepseek-ai/libreoffice-kit', '@deepseek-ai/libreoffice-kit-wasm', + '@deepseek-ai/libreoffice-kit-darwin-arm64', '@deepseek-ai/libreoffice-kit-darwin-x64', + '@deepseek-ai/libreoffice-kit-win32-arm64', '@deepseek-ai/libreoffice-kit-win32-x64', + ]) { + expect(() => { assertRuntimeLicenses([{ name, license: 'MPL-2.0' }]) }).not.toThrow() + expect(() => { assertRuntimeLicenses([{ name, license: 'GPL-3.0-only' }]) }).toThrow(name) + } + for (const dependency of [ + { name: 'unrelated-library', license: 'MPL-2.0' }, + { name: '@deepseek-ai/dsh-libreoffice-kit', license: 'MPL-2.0' }, + { name: '@deepseek-ai/libreoffice-kit-unreviewed', license: 'MPL-2.0' }, + { name: '@deepseek-ai/libreoffice-kit', license: 'GPL-3.0-only' }, + { name: '@deepseek-ai/libreoffice-kit', license: 'UNKNOWN' }, + ]) { + expect(() => { assertRuntimeLicenses([dependency]) }).toThrow(`${dependency.name} (${dependency.license})`) + } + expect(isPermissive('MPL-2.0')).toBe(false) + }) + it('keeps license rejection active when a browser library is declared for development', () => { const { manifests, names } = workspace({ 'packages/client/ui/package.json': { devDependencies: { 'browser-lib': '^1', 'test-tool': '^1' } }, diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f0ff461f8c..c77fbd6b38 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -53,6 +53,15 @@ const FIRST_PARTY = new Set([ export const CLAUDE_AGENT_SDK_PACKAGE = '@anthropic-ai/claude-agent-sdk' const CLAUDE_PLATFORM_PACKAGE_PREFIX = `${CLAUDE_AGENT_SDK_PACKAGE}-` const CLAUDE_PLATFORM_DECLARED_LICENSE = 'SEE LICENSE IN LICENSE.md' +const LIBREOFFICE_KIT_PACKAGE = '@deepseek-ai/libreoffice-kit' +const LIBREOFFICE_PACKAGES = new Set([ + LIBREOFFICE_KIT_PACKAGE, + '@deepseek-ai/libreoffice-kit-wasm', + '@deepseek-ai/libreoffice-kit-darwin-arm64', + '@deepseek-ai/libreoffice-kit-darwin-x64', + '@deepseek-ai/libreoffice-kit-win32-arm64', + '@deepseek-ai/libreoffice-kit-win32-x64', +]) /** * Whether a non-permissive runtime declaration has an identity-scoped owner @@ -680,7 +689,9 @@ export function isPermissive(license: string): boolean { * @throws When a runtime package has no permissive license or exact owner authorization. */ export function assertRuntimeLicenses(dependencies: readonly { name: string; license: string }[]): void { - const rejected = dependencies.filter(dep => !isPermissive(dep.license) && !isOwnerAuthorizedRuntime(dep.name)) + const rejected = dependencies.filter(dep => !isPermissive(dep.license) + && !isOwnerAuthorizedRuntime(dep.name) + && !(LIBREOFFICE_PACKAGES.has(dep.name) && dep.license === 'MPL-2.0')) if (rejected.length > 0) { throw new Error(`gen-third-party-notices: runtime ${rejected.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) } @@ -739,6 +750,7 @@ export async function render(): Promise { const npm = collectNpmDeps(manifests, names, browser) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) + const kitRuntime = runtimeDeps.some(dep => dep.name === LIBREOFFICE_KIT_PACKAGE) const vendored = collectVendored() const python = collectPython() const desktopPython = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages) @@ -782,6 +794,13 @@ pnpm applies local patches to the following packages at install time, so shipped ${patchedLines.join('\n')} ${renderClaudeDistribution(claudeDistribution)} +${kitRuntime ? ` +## LibreOffice conversion kit + +${[...LIBREOFFICE_PACKAGES].map(name => `\`${name}\``).join(', ')} declare MPL-2.0, which remains outside the permissive-license allowlist; the notices check accepts only these package identities at those terms. The [distribution decision](.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) records the source obligations. + +The [kit repository](https://github.com/deepseek-harness/libreoffice-kit) supplies the corresponding LibreOffice source pin, modifications, build instructions, Node API, and artifact validation. Its engine packages retain their license and third-party notices; the Node API retains its MPL-2.0 declaration and NOTICE. Recipients must have access to those corresponding sources and notices. +` : ''} ## Development-only npm dependencies diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index a84c9d95b6..e523813603 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -19,6 +19,7 @@ import sysconfig import tempfile import threading import time +import zipfile from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import TYPE_CHECKING, Callable @@ -708,7 +709,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "runner", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-live", "runner", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -727,13 +728,20 @@ def main() -> None: parser.error("--scenario sdk-profile-plugin requires --installed-wheel") if args.installed_wheel: args.exe = assert_installed_wheel_environment() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "runner", "direct"} and args.exe is None: - parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, runner, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "runner", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-snapshot", "sdk-restart"}: parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-snapshot, sdk-restart, or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") + if args.scenario in {"all", "sdk-office"}: + assert args.exe is not None + smoke_sdk_office(args.exe.resolve()) + if args.scenario == "sdk-office": + print("smoke-python-runtime: sdk-office passed") + return + if args.scenario in {"all", "runner"}: assert args.exe is not None smoke_packaged_runner(args.exe.resolve()) @@ -782,6 +790,69 @@ def main() -> None: print(f"smoke-python-runtime: {args.scenario} passed") +def smoke_sdk_office(executable: Path) -> None: + """Relocate the wheel payload and convert a real DOCX with the target platform engine.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-office-") as temporary: + root = Path(temporary).resolve() + relocated = root / executable.name + stem = executable.name.removesuffix(".exe") + for source in executable.parent.glob(f"{stem}*"): + destination = root / source.name + if source.is_dir(): + shutil.copytree(source, destination) + else: + shutil.copy2(source, destination) + office = root / f"{stem}-office" + expected_backend = "wasm" if sys.platform == "linux" else "native" + engines = [ + json.loads(manifest.read_text())["engine"]["kind"] + for manifest in (office / "node_modules/@deepseek-ai").glob("libreoffice-kit-*/prebuilds.json") + ] + if engines != [expected_backend]: + raise AssertionError(f"Office sidecar must contain only {expected_backend}: {engines}") + plugin = root / "office.mjs" + shutil.copy2(Path(__file__).resolve().parent / "fixtures/python-sdk-office.mjs", plugin) + document = root / "document.docx" + with zipfile.ZipFile(document, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("[Content_Types].xml", '') + archive.writestr("_rels/.rels", '') + archive.writestr("word/document.xml", 'Python Office wheel') + + mode = expected_backend + output = root / f"{mode}.pdf" + result_path = root / f"{mode}.json" + patch = root / f"{mode}.patch.yml" + patch.write_text(json.dumps([{"insert": [{ + "id": "python-sdk-office-smoke", + "name": plugin.as_uri(), + "config": {"input": str(document), "output": str(output), "result": str(result_path)}, + }]}])) + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + dsh_bin=str(relocated), + dsh_home=str(root / f"home-{mode}"), + patches=(str(patch),), + api_key="sk-keyless-smoke", + base_url="http://127.0.0.1:9", + env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"}, + request_timeout_seconds=180, + ): + pass + result = json.loads(result_path.read_text()) + if result["backend"] != expected_backend: + raise AssertionError(f"Office conversion did not use {expected_backend}: {result}") + if not result["moduleUrl"].startswith(office.as_uri() + "/"): + raise AssertionError(f"Office module was not loaded from the relocated wheel: {result}") + pdf = output.read_bytes() + if len(pdf) < 100 or not pdf.startswith(b"%PDF-"): + raise AssertionError(f"Office conversion produced an invalid PDF at {output}") + print(f"smoke-python-runtime: relocated Office {result['backend']} DOCX produced {len(pdf)} PDF bytes") + + def assert_installed_wheel_environment() -> Path: """Prove that this process imports matching non-editable wheel installations.""" if sys.prefix == sys.base_prefix: diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index cfe65c4bb1..f33f5c00ba 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -51,6 +51,27 @@ afterEach(() => { }) describe('default product isolation', () => { + it.each(['@deepseek-ai/libreoffice-kit'])( + 'accepts independently published %s but rejects unknown workspace packages', (name) => { + const root = fixture() + const file = 'packages/core/core/package.json' + write(root, 'packages/core/core/src/index.ts', `import '${name}'\n`) + manifest(root, file, { dependencies: { [name]: '0.0.1' } }) + expect(verifyDefaultProductIsolation(root).failures).toEqual([]) + manifest(root, file, { dependencies: { [`${name}-unknown`]: '0.0.1' } }) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain('unknown workspace package') + }, + ) + + it.each(['wasm', 'darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64'])( + 'rejects a direct dependency on the %s engine', (engine) => { + const root = fixture() + const name = `@deepseek-ai/libreoffice-kit-${engine}` + manifest(root, 'packages/core/core/package.json', { dependencies: { [name]: '0.0.1' } }) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`unknown workspace package ${name}`) + }, + ) + it('allows development dependencies, type imports, and separate experimental preview entries', () => { const root = fixture() manifest(root, 'apps/cli/package.json', { devDependencies: { [experimental]: 'workspace:^' } }) diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index 45576d5e8e..5e3acc7fc9 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -20,6 +20,8 @@ import { } from './verify-client-packages.ts' const EXPERIMENTAL_PREFIX = '@deepseek-ai/dsh-experimental-' +// The independently published entry package owns platform-engine dependencies. +const EXTERNAL_KIT_PACKAGES = new Set(['@deepseek-ai/libreoffice-kit']) const PROFILE_SOURCE = 'packages/boot/app-boot/src/profile.ts' const PRESET_PATTERN = 'packages/preset/agent-presets/presets/*/agent.cordis.yml' const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const @@ -129,6 +131,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes } const pkg = packages.get(packageName) if (pkg !== undefined) add(pkg, origin) + else if (EXTERNAL_KIT_PACKAGES.has(packageName)) return else if (packageName.startsWith('@deepseek-ai/')) failures.push(`${origin}: unknown workspace package ${name}`) } const dependency = (name: string, range: string, owner: Package, origin: string): void => { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6b23a59979..6d3cec5129 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -53,6 +53,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' }, 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, + 'packages/document/document-render': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, + 'packages/document/document-render-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/scripts/verify-repository-references.spec.ts b/scripts/verify-repository-references.spec.ts index f01ea346af..8b2f0a9d3c 100644 --- a/scripts/verify-repository-references.spec.ts +++ b/scripts/verify-repository-references.spec.ts @@ -44,6 +44,15 @@ function repository(test: TestContext) { } describe('maintained repository reference policy', () => { + it('permits only the independent kit repository and its source URLs', () => { + for (const suffix of ['', '.git', '/tree/main/packages/entry']) { + expect(findRepositoryReferences('package.json', `${organizationUrl}/libreoffice-kit${suffix}`, new Set())).toEqual([]) + } + for (const suffix of ['-other', '.example', 's']) { + expect(findRepositoryReferences('package.json', `${organizationUrl}/libreoffice-kit${suffix}`, new Set())).toHaveLength(1) + } + }) + it('rejects complete and abbreviated commit identifiers in tracked, staged, and new files', (test) => { const fixture = repository(test) fixture.write('tracked.md', `release\n${fixture.commit}\n${fixture.commit.toUpperCase()}\n`) diff --git a/scripts/verify-repository-references.ts b/scripts/verify-repository-references.ts index 4f7ddcb563..92ad6abacc 100644 --- a/scripts/verify-repository-references.ts +++ b/scripts/verify-repository-references.ts @@ -9,6 +9,8 @@ import { canonicalReferenceText } from './verify-public-repository-links.ts' const root = resolve(import.meta.dirname, '..') const organization = ['deepseek', 'harness'].join('-') const organizationUrl = new RegExp(`\\bgithub\\.com/${organization}(?![a-z0-9-])`) +// The independent kit repository owns the engine source and documentation. +const kitRepositoryUrl = new RegExp(`\\bgithub\\.com/${organization}/libreoffice-kit(?:\\.git)?(?=/|[^a-zA-Z0-9_.-]|$)`, 'g') const commitCandidate = /(? commits.has(match[0].toLowerCase()))) { diff --git a/tsconfig.base.json b/tsconfig.base.json index ed259b9d25..ea876af450 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -325,6 +325,8 @@ "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], + "@deepseek-ai/dsh-document-render": ["./packages/document/document-render/src"], + "@deepseek-ai/dsh-document-render-libreoffice": ["./packages/document/document-render-libreoffice/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index a23a99b112..5367222539 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -160,6 +160,8 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, + { "path": "./packages/document/document-render" }, + { "path": "./packages/document/document-render-libreoffice" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From a193b51c96e71d5041ccb8c897cc305cec24034a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 13:59:49 +0800 Subject: [PATCH 2/9] refactor(document): name Office PDF conversion explicitly --- ...9-14-independent-libreoffice-kit.i18n.yaml | 4 +- .../2026-09-14-independent-libreoffice-kit.md | 2 +- ...26-09-14-independent-libreoffice-kit.zh.md | 2 +- ...6-09-15-bounded-office-rendering.i18n.yaml | 4 +- .../2026-09-15-bounded-office-rendering.md | 4 +- .../2026-09-15-bounded-office-rendering.zh.md | 4 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 12 +-- docs/capability-seams.zh.md | 12 +-- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 2 +- docs/subsystems/README.zh.md | 2 +- ...r.i18n.yaml => document-convert.i18n.yaml} | 6 +- ...document-render.md => document-convert.md} | 26 +++---- ...nt-render.zh.md => document-convert.zh.md} | 26 +++---- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 4 +- .../web-app/tests/document-conversion.e2e.ts | 24 +++--- packages/document/README.i18n.yaml | 4 +- packages/document/README.md | 12 +-- packages/document/README.zh.md | 12 +-- .../README.i18n.yaml | 6 +- .../README.md | 10 +-- .../README.zh.md | 10 +-- .../package.json | 8 +- .../src/index.ts | 30 +++---- .../src/output.ts | 12 +-- .../src/queue.ts | 50 ++++++------ .../tests/output.spec.ts | 0 .../tests/provider.spec.ts | 78 +++++++++---------- .../tests/queue.spec.ts | 20 ++--- .../tsconfig.json | 2 +- .../README.i18n.yaml | 6 +- .../README.md | 8 +- .../README.zh.md | 8 +- .../package.json | 4 +- .../document/document-convert/src/index.ts | 44 +++++++++++ .../src/types.ts | 28 +++---- .../tsconfig.json | 0 .../document/document-render/src/index.ts | 44 ----------- .../node/external_packages/libreoffice-kit.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 50 ++++++------ pnpm-lock.yaml | 16 ++-- scripts/gen-cordis-catalog.ts | 10 +-- scripts/gen-doc-graphs.ts | 4 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 5 +- tsconfig.host.json | 4 +- 57 files changed, 339 insertions(+), 334 deletions(-) rename docs/subsystems/{document-render.i18n.yaml => document-convert.i18n.yaml} (67%) rename docs/subsystems/{document-render.md => document-convert.md} (59%) rename docs/subsystems/{document-render.zh.md => document-convert.zh.md} (58%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/README.i18n.yaml (67%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/README.md (79%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/README.zh.md (88%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/package.json (80%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/src/index.ts (84%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/src/output.ts (69%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/src/queue.ts (82%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tests/output.spec.ts (100%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tests/provider.spec.ts (80%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tests/queue.spec.ts (96%) rename packages/document/{document-render-libreoffice => document-convert-libreoffice}/tsconfig.json (89%) rename packages/document/{document-render => document-convert}/README.i18n.yaml (69%) rename packages/document/{document-render => document-convert}/README.md (69%) rename packages/document/{document-render => document-convert}/README.zh.md (67%) rename packages/document/{document-render => document-convert}/package.json (90%) create mode 100644 packages/document/document-convert/src/index.ts rename packages/document/{document-render => document-convert}/src/types.ts (77%) rename packages/document/{document-render => document-convert}/tsconfig.json (100%) delete mode 100644 packages/document/document-render/src/index.ts diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml index cb10a38c27..7f6119ae93 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.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-14-independent-libreoffice-kit.md -2026-09-14-independent-libreoffice-kit.md: aaecb7468c16451ed812c9b2441e55e00abf6c98 -2026-09-14-independent-libreoffice-kit.zh.md: 8cea9890596bc53ddcbe346c60c654b49e0362c8 +2026-09-14-independent-libreoffice-kit.md: f9f27585d157025477e5f5d5f6d74dfca142ce83 +2026-09-14-independent-libreoffice-kit.zh.md: 18bf20e28c8f66ea581ac7823f7b004e89153b90 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md index aaecb7468c..f9f27585d1 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -10,7 +10,7 @@ LibreOffice compilation, source patches, platform qualification, and large binar ## Decision -The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `DocumentRenderer` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. +The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `DocumentConverter` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md index 8cea989059..18bf20e28c 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -10,7 +10,7 @@ LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布 ## Decision -`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentRenderer` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentConverter` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml index 44528f3fc0..9cb08e42e0 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.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-15-bounded-office-rendering.md -2026-09-15-bounded-office-rendering.md: 539eb7e4824897861f350f294365f283678a7fee -2026-09-15-bounded-office-rendering.zh.md: 928d93a6500d6c7818f28dd20029648453d9f9cf +2026-09-15-bounded-office-rendering.md: c8ffd5f476e9c5a7c7a0f85706872b2da1690681 +2026-09-15-bounded-office-rendering.zh.md: a03a605dd66142d6457d35154a0e1628dcfa7632 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md index 539eb7e482..c8ffd5f476 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -10,7 +10,9 @@ Office preview and explicit document inspection can request the same conversion. ## Decision -The [Host provider](../../../../packages/document/document-render-libreoffice/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. +The `document-convert` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. + +The [Host provider](../../../../packages/document/document-convert-libreoffice/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md index 928d93a650..a03a605dd6 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -10,7 +10,9 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 ## 决策 -[宿主提供方](../../../../packages/document/document-render-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 +`document-convert` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 + +[宿主提供方](../../../../packages/document/document-convert-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 9dd51946fb..c582de9b25 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: 497f086217521dc081aa094d2a61a7ae7a362893 -README.zh.md: 687166b0c04994e503d62fb77bf07a2c030223d4 +README.md: 7aa419b5ba405d15eeffc61831486c9648bea81c +README.zh.md: a2c5e9877fc7816a401feac50404818f1fdc5fe5 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 497f086217..7aa419b5ba 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -124,7 +124,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/document-render-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 687166b0c0..a2c5e9877f 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -124,7 +124,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/document-render-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 153fe3a5ca..37cbf2e0c9 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 90d77872b47c1ac610d566c29f6c172e74bd79f3 -capability-seams.zh.md: 62a0b545e47cfc6b3c5cf30fa76e3ca332c7ed7d +capability-seams.md: 0a6b0ae80e1cbab9f6434035ac07cd4adb3fe7ec +capability-seams.zh.md: 87613f930550d03ac3c42d57dcb73860d1242916 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 90d77872b4..0a6b0ae80e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,9 +26,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_render["document-render"] - svc_documentRender["ctx.documentRender
Office to PDF rendering"] - pkg_document_render_libreoffice["document-render-libreoffice"] + pkg_document_convert["document-convert"] + svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -286,8 +286,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_render --> svc_documentRender - pkg_document_render_libreoffice --> svc_documentRender + pkg_document_convert --> svc_documentConvert + pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -538,7 +538,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | -| `ctx.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 62a0b545e4..87613f9305 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,9 +28,9 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_render["document-render"] - svc_documentRender["ctx.documentRender
Office to PDF rendering"] - pkg_document_render_libreoffice["document-render-libreoffice"] + pkg_document_convert["document-convert"] + svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -288,8 +288,8 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_render --> svc_documentRender - pkg_document_render_libreoffice --> svc_documentRender + pkg_document_convert --> svc_documentConvert + pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -540,7 +540,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | -| `ctx.documentRender` | `seam` | [`document-render`](../packages/document/document-render) | [`document-render-libreoffice`](../packages/document/document-render-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bfaed2cd89..0e0def93c2 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: 146af3692b84672a09039ed56b19a54470c3c47f -config-catalog.zh.md: 8e140676fca4ce6ea81258c1e8b0cbd21e254ccf +config-catalog.md: 19347950d5ae1bca252342ca84879c700a71a4ad +config-catalog.zh.md: 17cd1130a1d7c17bb1714a4084b41366041f66e4 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 146af3692b..19347950d5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,9 +548,9 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - + -## `@deepseek-ai/dsh-document-render-libreoffice` +## `@deepseek-ai/dsh-document-convert-libreoffice` ```ts config-catalog /** Provider concurrency and kit rendering/font configuration. */ @@ -596,7 +596,7 @@ export interface Config { } ``` -Source: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) +Source: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) @@ -3848,7 +3848,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-render` — abstract `DocumentRenderer` ([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) +- `@deepseek-ai/dsh-document-convert` — abstract `DocumentConverter` ([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8e140676fc..17cd1130a1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -550,9 +550,9 @@ export interface Config { 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - + -## `@deepseek-ai/dsh-document-render-libreoffice` +## `@deepseek-ai/dsh-document-convert-libreoffice` ```ts config-catalog /** Provider concurrency and kit rendering/font configuration. */ @@ -598,7 +598,7 @@ export interface Config { } ``` -源码: [`packages/document/document-render-libreoffice/src/index.ts:14`](../packages/document/document-render-libreoffice/src/index.ts) +源码: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) @@ -3850,7 +3850,7 @@ export interface Config { - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-render` — 抽象 `DocumentRenderer`([`packages/document/document-render/src/index.ts`](../packages/document/document-render/src/index.ts)) +- `@deepseek-ai/dsh-document-convert` — 抽象 `DocumentConverter`([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index b0a4c765f9..c3bebe788a 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: e6c0e0d1b6f7428c6af40af110d744afda6fadbf -README.zh.md: d7b640a383e88c3aa0364682ddd8df2bf8ffce85 +README.md: 0f67589082e06a3a882a531138f05efbf54df702 +README.zh.md: 11522b1049b8dacdbe72ec131a80372e2a075e5f diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index e6c0e0d1b6..0f67589082 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -30,7 +30,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [mcp.md](mcp.md) | external MCP connections, scoped tools and resources, server instructions, protocol results, and configuration ownership | | [user-questions.md](user-questions.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts | -| [document-render.md](document-render.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | +| [document-convert.md](document-convert.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [shell.md](shell.md) | the shell executor seam: `ShellExecRequest`/`Spec`, `ShellRunResult`, background `ShellProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index d7b640a383..11522b1049 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -30,7 +30,7 @@ | [mcp.md](mcp.zh.md) | 外部 MCP 连接、作用域工具与资源、服务器指令、协议结果与配置归属 | | [user-questions.md](user-questions.zh.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.zh.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | -| [document-render.md](document-render.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | +| [document-convert.md](document-convert.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | | [attachment.md](attachment.zh.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [shell.md](shell.zh.md) | shell 执行器 seam:`ShellExecRequest`/`Spec`、`ShellRunResult`、后台 `ShellProcess` 句柄 | | [subprocess.md](subprocess.zh.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | diff --git a/docs/subsystems/document-render.i18n.yaml b/docs/subsystems/document-convert.i18n.yaml similarity index 67% rename from docs/subsystems/document-render.i18n.yaml rename to docs/subsystems/document-convert.i18n.yaml index ab3117feae..8ec3e22776 100644 --- a/docs/subsystems/document-render.i18n.yaml +++ b/docs/subsystems/document-convert.i18n.yaml @@ -1,6 +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 docs/subsystems/document-render.md -document-render.md: f3e49fb45f2cc19956b6385cf7390825fa9d4c60 -document-render.zh.md: 79028858e9e3f95ac1a4618d3b9342b78aa29cf2 +# pnpm run verify-translation-pairing --write docs/subsystems/document-convert.md +document-convert.md: 2dea31918e92524606ad85826de276c2af05f06d +document-convert.zh.md: 22f0ec54615ebbf86743de6320ec4e8cc3fc8113 diff --git a/docs/subsystems/document-render.md b/docs/subsystems/document-convert.md similarity index 59% rename from docs/subsystems/document-render.md rename to docs/subsystems/document-convert.md index f3e49fb45f..2dea31918e 100644 --- a/docs/subsystems/document-render.md +++ b/docs/subsystems/document-convert.md @@ -1,6 +1,6 @@ -# Document rendering +# Document conversion -English | [中文](document-render.zh.md) +English | [中文](document-convert.zh.md) The [document package family](../../packages/document/README.md) converts Office files to PDFs on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns conversion, bounded admission, and transient PDF reuse. This subsystem creates no model-facing tool or Session event. @@ -8,21 +8,21 @@ The [document package family](../../packages/document/README.md) converts Office | Owner | Responsibility | |---|---| -| [document-render](../../packages/document/document-render/README.md) | `ctx.documentRender`: authorized Office bytes and complete PDF results | -| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | +| [document-convert](../../packages/document/document-convert/README.md) | `ctx.documentConvert`: authorized Office bytes and complete PDF results | +| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | ## Requests and results -[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentRenderer.render(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentRenderError`. +[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentConverter.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentConvertError`. -`DocumentRenderPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentRendererGeneration` brands a provider lifetime, and `DocumentRenderKey` brands its content identity; neither opaque value is parsed by consumers. +`DocumentConvertPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentConverterGeneration` brands a provider lifetime, and `DocumentConvertKey` brands its content identity; neither opaque value is parsed by consumers. | Result field | Meaning | |---|---| | `pdf` | Caller-owned `Uint8Array` containing the complete PDF | | `missingFonts` | Requested document font families unavailable to this conversion | -| `cacheKey` | Opaque renderer generation plus extension/source-content identity | +| `cacheKey` | Opaque converter generation plus extension/source-content identity | | `generation` | Provider lifetime; replacement invalidates cached PDF reuse | The provider admits the deferred read before allocating source bytes, shares conversions by content identity, and removes its private scratch directory before returning. Returned PDF bytes remain valid after provider disposal. Source and PDF bytes do not enter Session storage. Consumers can use [Workspace Files](../../packages/api/workspace-files/README.md) for authorized bounded reads. @@ -31,7 +31,7 @@ The provider admits the deferred read before allocating source bytes, shares con The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. -The [Host provider configuration](../../packages/document/document-render-libreoffice/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. +The [Host provider configuration](../../packages/document/document-convert-libreoffice/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. @@ -41,9 +41,9 @@ The [Host provider configuration](../../packages/document/document-render-libreo Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentRender` — `DocumentRenderer` (abstract seam) +### `ctx.documentConvert` — `DocumentConverter` (abstract seam) Load one provider subclass per context; consumers own source authorization. @@ -53,10 +53,10 @@ Load one provider subclass per context; consumers own source authorization. * @param request - authorized metadata and deferred bounded source read. * @param signal - caller cancellation; provider disposal also stops active work. * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) +Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) diff --git a/docs/subsystems/document-render.zh.md b/docs/subsystems/document-convert.zh.md similarity index 58% rename from docs/subsystems/document-render.zh.md rename to docs/subsystems/document-convert.zh.md index 79028858e9..22f0ec5461 100644 --- a/docs/subsystems/document-render.zh.md +++ b/docs/subsystems/document-convert.zh.md @@ -1,6 +1,6 @@ -# 文档渲染 +# 文档转换 -[English](document-render.md) | 中文 +[English](document-convert.md) | 中文 [document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 @@ -8,21 +8,21 @@ | 所有者 | 职责 | |---|---| -| [document-render](../../packages/document/document-render/README.zh.md) | `ctx.documentRender`:已授权 Office 字节和完整 PDF 结果 | -| [document-render-libreoffice](../../packages/document/document-render-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | +| [document-convert](../../packages/document/document-convert/README.zh.md) | `ctx.documentConvert`:已授权 Office 字节和完整 PDF 结果 | +| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | ## 请求和结果 -[`DocumentRenderRequest`](../../packages/document/document-render/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentRenderer.render(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentRenderError` 拒绝。 +[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentConverter.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentConvertError` 拒绝。 -`DocumentRenderPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentRendererGeneration` 表示提供方生命周期,`DocumentRenderKey` 表示其内容身份;消费者不解析这两种不透明值。 +`DocumentConvertPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentConverterGeneration` 表示提供方生命周期,`DocumentConvertKey` 表示其内容身份;消费者不解析这两种不透明值。 | 结果字段 | 含义 | |---|---| | `pdf` | 调用方拥有的 `Uint8Array`,包含完整 PDF | | `missingFonts` | 本次转换无法使用的文档请求字体名称 | -| `cacheKey` | 不透明的渲染 generation 加扩展名与源内容身份 | +| `cacheKey` | 不透明的转换 generation 加扩展名与源内容身份 | | `generation` | 提供方生命周期;替换后缓存 PDF 不再可复用 | 提供方先准入延迟读取,再分配源文件字节;按内容身份共享转换,并在返回前删除私有临时目录。返回的 PDF 字节在提供方释放后仍有效。源文件和 PDF 字节不会进入 Session 存储。消费者可通过[工作区文件](../../packages/api/workspace-files/README.zh.md)执行已授权的有界读取。 @@ -31,7 +31,7 @@ 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 -[Host 提供方配置](../../packages/document/document-render-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 +[Host 提供方配置](../../packages/document/document-convert-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 @@ -41,9 +41,9 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentRender` — `DocumentRenderer` (abstract seam) +### `ctx.documentConvert` — `DocumentConverter` (abstract seam) Load one provider subclass per context; consumers own source authorization. @@ -53,10 +53,10 @@ Load one provider subclass per context; consumers own source authorization. * @param request - authorized metadata and deferred bounded source read. * @param signal - caller cancellation; provider disposal also stops active work. * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise +abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-render/src/index.ts`](../../packages/document/document-render/src/index.ts) +Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 807ca3fb09..c2f7020c88 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-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/web-app/README.md -README.md: 0057516fb5bfba2708923e08af20f6e59f53f772 -README.zh.md: 1be494520d18311cae1f8dfca00c682b597f889d +README.md: 2b5c4f48f95fc40b7e25c33e4b14a510c0ace34f +README.zh.md: a8117f8f7d1c3f12016d37c37b81e5a8734531aa diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 0057516fb5..2b5c4f48f9 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-render` row mounts one lazy [Office conversion provider](../../document/document-render-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. +The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-convert` row mounts one lazy [Office conversion provider](../../document/document-convert-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 1be494520d..a8117f8f7d 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-render` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-render-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-convert` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-convert-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 863fea96d5..4f20b57769 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,8 +225,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' - - id: document-render - name: '@deepseek-ai/dsh-document-render-libreoffice' + - id: document-convert + name: '@deepseek-ai/dsh-document-convert-libreoffice' # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, and plain-text renderers. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 50bd19af36..04a344f8e4 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -123,8 +123,8 @@ "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-document-render": "workspace:^", - "@deepseek-ai/dsh-document-render-libreoffice": "workspace:^" + "@deepseek-ai/dsh-document-convert": "workspace:^", + "@deepseek-ai/dsh-document-convert-libreoffice": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts index 3cab818548..2f66681703 100644 --- a/packages/bundle/web-app/tests/document-conversion.e2e.ts +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' -import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' -import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-render-libreoffice' +import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-convert-libreoffice' import { expect, it, onTestFinished } from 'vitest' it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => { @@ -20,9 +20,9 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }) const rows = loadOverlayPatches('conversion-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) .flatMap(patch => patch.insert ?? []) - .filter(row => row.name === '@deepseek-ai/dsh-document-render-libreoffice') - expect(rows.map(row => row.id)).toEqual(['document-render']) - const configured = applyEntryPatches(rows, [{ id: 'document-render', config: { maxConcurrentConversions: 1 } }], + .filter(row => row.name === '@deepseek-ai/dsh-document-convert-libreoffice') + expect(rows.map(row => row.id)).toEqual(['document-convert']) + const configured = applyEntryPatches(rows, [{ id: 'document-convert', config: { maxConcurrentConversions: 1 } }], (message) => { throw new Error(message) }) const configPath = join(directory, 'cordis.yml') await writeFile(configPath, JSON.stringify(configured)) @@ -33,19 +33,19 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal ctx.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-document-render-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) + if (specifier !== '@deepseek-ai/dsh-document-convert-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) return LibreOfficeProvider }, } as unknown as NonNullable await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() - const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'document-render')! + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'document-convert')! await entry.fiber!.await() expect(ctx.get('documentRenderController')).toBeUndefined() expect(ctx.get('skills')).toBeUndefined() const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url)) let reads = 0 - const request = (key: string): DocumentRenderRequest => ({ + const request = (key: string): DocumentConvertRequest => ({ extension: 'docx', priority: 'foreground', source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length, async read(signal, maxBytes) { @@ -56,8 +56,8 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }, }, }) - const provider = ctx.documentRender - const [first, second] = await Promise.all([provider.render(request('first')), provider.render(request('second'))]) + const provider = ctx.documentConvert + const [first, second] = await Promise.all([provider.convert(request('first')), provider.convert(request('second'))]) expect(reads).toBe(2) expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-') expect(first.cacheKey).toBe(second.cacheKey) @@ -66,11 +66,11 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal expect(first.missingFonts).not.toBe(second.missingFonts) first.pdf.fill(0) first.missingFonts.push('caller-owned') - const cached = await provider.render(request('first')) + const cached = await provider.convert(request('first')) expect(reads).toBe(2) expect(cached.pdf).toEqual(second.pdf) expect(cached.missingFonts).toEqual(second.missingFonts) await ctx.fiber.dispose() expect(Buffer.from(cached.pdf).subarray(0, 5).toString()).toBe('%PDF-') - await expect(provider.render(request('first'))).rejects.toMatchObject({ code: 'unavailable' }) + await expect(provider.convert(request('first'))).rejects.toMatchObject({ code: 'unavailable' }) }) diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml index d31937a0b5..5c7acd04ef 100644 --- a/packages/document/README.i18n.yaml +++ b/packages/document/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/document/README.md -README.md: 4ccf8e9c7267b7442ee08a218b51e50a4909aae3 -README.zh.md: c91beff7d3f6a028d9a1553308714c0328ab35de +README.md: 0f9d3f1ae0cb154a445e34debe0bf49ef5400d1e +README.zh.md: 0325634d6c435b6d8aeebf202d04f84fa4975e33 diff --git a/packages/document/README.md b/packages/document/README.md index 4ccf8e9c72..0f9d3f1ae0 100644 --- a/packages/document/README.md +++ b/packages/document/README.md @@ -3,13 +3,13 @@ description: "Package map for Host Office conversion and reusable PDF results." kind: "package-group" --- -# document/ — Office rendering +# document/ — Office conversion English | [中文](README.zh.md) ## Summary -Convert authorized Office files to reusable PDFs on the Host. The rendering family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. +Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. ## Table of Contents @@ -22,12 +22,12 @@ Convert authorized Office files to reusable PDFs on the Host. The rendering fami ## Packages -Each package owns its configuration and lifetime rules; the subsystem reference describes their shared rendering operation. +Each package owns its configuration and lifetime rules; the subsystem reference describes their shared conversion operation. | Package | Role | ctx key | |---|---|---| -| [document-render](document-render/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentRender` | -| [document-render-libreoffice](document-render-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentRender` | +| [document-convert](document-convert/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentConvert` | +| [document-convert-libreoffice](document-convert-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentConvert` | ----- @@ -36,7 +36,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference Consumers own source authorization and presentation. -- [Document rendering](../../docs/subsystems/document-render.md) — shared operation and generated service reference. +- [Document conversion](../../docs/subsystems/document-convert.md) — shared operation and generated service reference. - [Independent kit ownership](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) — engine distribution and application integration. - [Workspace Files](../api/workspace-files/README.md) — authorized bounded source reads. diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md index c91beff7d3..0325634d6c 100644 --- a/packages/document/README.zh.md +++ b/packages/document/README.zh.md @@ -3,13 +3,13 @@ description: "Host Office 转换和可复用 PDF 结果的包索引。" kind: "package-group" --- -# document/ — Office 渲染 +# document/ — Office 转换 [English](README.md) | 中文 ## 摘要 -在宿主上将已授权的 Office 文件转换为可复用的 PDF。渲染包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 +在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 ## 目录 @@ -22,12 +22,12 @@ kind: "package-group" ## 包 -每个包负责自身配置和生命周期规则;子系统参考描述共享渲染操作。 +每个包负责自身配置和生命周期规则;子系统参考描述共享转换操作。 | 包 | 职责 | ctx 键 | |---|---|---| -| [document-render](document-render/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentRender` | -| [document-render-libreoffice](document-render-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentRender` | +| [document-convert](document-convert/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentConvert` | +| [document-convert-libreoffice](document-convert-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentConvert` | ----- @@ -36,7 +36,7 @@ kind: "package-group" 消费者负责源文件授权与展示。 -- [文档渲染](../../docs/subsystems/document-render.zh.md) — 共享操作和生成的服务参考。 +- [文档转换](../../docs/subsystems/document-convert.zh.md) — 共享操作和生成的服务参考。 - [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。 - [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。 diff --git a/packages/document/document-render-libreoffice/README.i18n.yaml b/packages/document/document-convert-libreoffice/README.i18n.yaml similarity index 67% rename from packages/document/document-render-libreoffice/README.i18n.yaml rename to packages/document/document-convert-libreoffice/README.i18n.yaml index 1fde4e33b9..13f28d09b0 100644 --- a/packages/document/document-render-libreoffice/README.i18n.yaml +++ b/packages/document/document-convert-libreoffice/README.i18n.yaml @@ -1,6 +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/document/document-render-libreoffice/README.md -README.md: 3315498c928a5613809ad3f75a1d9fe3e4bc07d2 -README.zh.md: e39f5824d5f969d997914848ebdd6dc767186dc7 +# pnpm run verify-translation-pairing --write packages/document/document-convert-libreoffice/README.md +README.md: 1c287b714ace289fa03f8be6da722fbe78e64065 +README.zh.md: 796d14c3bd873a3a6a24d4691f0d406d0894a122 diff --git a/packages/document/document-render-libreoffice/README.md b/packages/document/document-convert-libreoffice/README.md similarity index 79% rename from packages/document/document-render-libreoffice/README.md rename to packages/document/document-convert-libreoffice/README.md index 3315498c92..1c287b714a 100644 --- a/packages/document/document-render-libreoffice/README.md +++ b/packages/document/document-convert-libreoffice/README.md @@ -3,7 +3,7 @@ description: "Host Office conversion with the independently published LibreOffic kind: "package-reference" --- -# @deepseek-ai/dsh-document-render-libreoffice +# @deepseek-ai/dsh-document-convert-libreoffice English | [中文](README.zh.md) @@ -25,7 +25,7 @@ Convert Office documents to PDFs on the Host computer. macOS and Windows use the ## Use this package -The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-render`. Independent compositions mount `@deepseek-ai/dsh-document-render-libreoffice` as a `cordis.yml` row. +The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-convert`. Independent compositions mount `@deepseek-ai/dsh-document-convert-libreoffice` as a `cordis.yml` row. The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. @@ -38,9 +38,9 @@ The provider depends on the independently published [`@deepseek-ai/libreoffice-k | `maxImageResolution` | `192` | Maximum raster-image DPI; overrides the kit default of `144`. | | `fontFallbacks` | Kit defaults | Ordered font-family preference groups; each group requires at least two names containing non-whitespace characters. | -The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-document-render-libreoffice) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. +The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-document-convert-libreoffice) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. -The provider retains successful PDFs by renderer generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready. +The provider retains successful PDFs by converter generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready. Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles. @@ -65,7 +65,7 @@ Each concurrent slot lazily creates and reuses one kit converter. The provider w ## Further Exploration -- [Rendering service](../document-render/README.md) — input and result ownership. +- [Rendering service](../document-convert/README.md) — input and result ownership. - [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. ----- diff --git a/packages/document/document-render-libreoffice/README.zh.md b/packages/document/document-convert-libreoffice/README.zh.md similarity index 88% rename from packages/document/document-render-libreoffice/README.zh.md rename to packages/document/document-convert-libreoffice/README.zh.md index e39f5824d5..796d14c3bd 100644 --- a/packages/document/document-render-libreoffice/README.zh.md +++ b/packages/document/document-convert-libreoffice/README.zh.md @@ -3,7 +3,7 @@ description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文 kind: "package-reference" --- -# @deepseek-ai/dsh-document-render-libreoffice +# @deepseek-ai/dsh-document-convert-libreoffice [English](README.md) | 中文 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -[Web bundle](../../bundle/web-app/README.zh.md)以 `document-render` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-render-libreoffice`。 +[Web bundle](../../bundle/web-app/README.zh.md)以 `document-convert` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-convert-libreoffice`。 此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 @@ -38,9 +38,9 @@ kind: "package-reference" | `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 | | `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 | -[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-render-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-convert-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 -提供方按渲染 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 +提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 @@ -65,7 +65,7 @@ kind: "package-reference" ## 延伸阅读 -- [渲染服务](../document-render/README.zh.md) — 输入和结果所有权。 +- [渲染服务](../document-convert/README.zh.md) — 输入和结果所有权。 - [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 ----- diff --git a/packages/document/document-render-libreoffice/package.json b/packages/document/document-convert-libreoffice/package.json similarity index 80% rename from packages/document/document-render-libreoffice/package.json rename to packages/document/document-convert-libreoffice/package.json index 3adb8d4203..f16000844f 100644 --- a/packages/document/document-render-libreoffice/package.json +++ b/packages/document/document-convert-libreoffice/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-document-render-libreoffice", + "name": "@deepseek-ai/dsh-document-convert-libreoffice", "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM", "version": "0.1.6-alpha.1", "publishConfig": { @@ -12,7 +12,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-render-libreoffice" + "directory": "packages/document/document-convert-libreoffice" }, "exports": { ".": { @@ -28,11 +28,11 @@ ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-render": "workspace:^" + "@deepseek-ai/dsh-document-convert": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-render": "workspace:^", + "@deepseek-ai/dsh-document-convert": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^" }, diff --git a/packages/document/document-render-libreoffice/src/index.ts b/packages/document/document-convert-libreoffice/src/index.ts similarity index 84% rename from packages/document/document-render-libreoffice/src/index.ts rename to packages/document/document-convert-libreoffice/src/index.ts index 21296d11f0..3f4ef308c2 100644 --- a/packages/document/document-render-libreoffice/src/index.ts +++ b/packages/document/document-convert-libreoffice/src/index.ts @@ -5,10 +5,10 @@ import { tmpdir } from 'node:os' import { isAbsolute, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit' -import { DocumentRenderer, DocumentRenderError, DocumentRendererGeneration, type DocumentExtension, type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import { DocumentConverter, DocumentConvertError, DocumentConverterGeneration, type DocumentExtension, type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' import z from '@deepseek-ai/schemastery' import { readPdf } from './output.ts' -import { RenderQueue } from './queue.ts' +import { ConversionQueue } from './queue.ts' /** Provider concurrency and kit rendering/font configuration. */ export interface Config { @@ -81,11 +81,11 @@ interface Slot { } /** A provider lifetime owns all converters, queued calls, and temporary files. */ -export class LibreOfficeRenderer extends DocumentRenderer { +export class LibreOfficeConverter extends DocumentConverter { static Config = Config - readonly generation = DocumentRendererGeneration(randomUUID()) + readonly generation = DocumentConverterGeneration(randomUUID()) private readonly slots: Slot[] = [] - private readonly queue: RenderQueue + private readonly queue: ConversionQueue private readonly options: ConverterOptions /** @@ -103,7 +103,7 @@ export class LibreOfficeRenderer extends DocumentRenderer { ...(fontDirectories === undefined ? {} : { fontDirectories }), ...(fontFallbacks === undefined ? {} : { fontFallbacks }), } - this.queue = new RenderQueue(config, this.generation, (bytes, extension, signal) => this.convert(bytes, extension, signal)) + this.queue = new ConversionQueue(config, this.generation, (bytes, extension, signal) => this.convertBytes(bytes, extension, signal)) ctx.effect(() => async () => { await this.queue.dispose() const results = await Promise.allSettled(this.slots.map(async (slot) => { @@ -115,11 +115,11 @@ export class LibreOfficeRenderer extends DocumentRenderer { }) } - render(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise { return this.queue.read(request, signal) } - private async convert(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { + private async convertBytes(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { signal.throwIfAborted() let slot = this.slots.find(candidate => !candidate.busy) if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) } @@ -134,7 +134,7 @@ export class LibreOfficeRenderer extends DocumentRenderer { } const converter = await slot.converter signal.throwIfAborted() - directory = await mkdtemp(join(tmpdir(), 'dsh-document-render-')) + directory = await mkdtemp(join(tmpdir(), 'dsh-document-convert-')) const inputPath = join(directory, `source.${extension}`) const outputPath = join(directory, 'converted.pdf') await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal }) @@ -144,20 +144,20 @@ export class LibreOfficeRenderer extends DocumentRenderer { let pdf: Uint8Array try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) } catch (cause) { - if (cause instanceof DocumentRenderError) throw cause - throw new DocumentRenderError('invalid-output', 'The converter PDF could not be read.', { cause }) + if (cause instanceof DocumentConvertError) throw cause + throw new DocumentConvertError('invalid-output', 'The converter PDF could not be read.', { cause }) } signal.throwIfAborted() return { pdf, missingFonts: result.missingFonts } } catch (cause) { signal.throwIfAborted() - if (cause instanceof DocumentRenderError) throw cause + if (cause instanceof DocumentConvertError) throw cause const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined switch (code) { case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format': case 'invalid-output': case 'timeout': case 'unavailable': - throw new DocumentRenderError(code, 'LibreOffice conversion failed.', { cause }) - default: throw new DocumentRenderError('failed', 'LibreOffice conversion failed.', { cause }) + throw new DocumentConvertError(code, 'LibreOffice conversion failed.', { cause }) + default: throw new DocumentConvertError('failed', 'LibreOffice conversion failed.', { cause }) } } finally { try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) } @@ -166,4 +166,4 @@ export class LibreOfficeRenderer extends DocumentRenderer { } } -export default LibreOfficeRenderer +export default LibreOfficeConverter diff --git a/packages/document/document-render-libreoffice/src/output.ts b/packages/document/document-convert-libreoffice/src/output.ts similarity index 69% rename from packages/document/document-render-libreoffice/src/output.ts rename to packages/document/document-convert-libreoffice/src/output.ts index 863c681020..41dee85ee2 100644 --- a/packages/document/document-render-libreoffice/src/output.ts +++ b/packages/document/document-convert-libreoffice/src/output.ts @@ -1,7 +1,7 @@ /** Read only the bounded, regular PDF created inside a private task directory. */ import { constants } from 'node:fs' import { lstat, open } from 'node:fs/promises' -import { DocumentRenderError } from '@deepseek-ai/dsh-document-render' +import { DocumentConvertError } from '@deepseek-ai/dsh-document-convert' /** * Refuse missing, link-shaped, oversized, truncated, and non-PDF output. @@ -13,12 +13,12 @@ import { DocumentRenderError } from '@deepseek-ai/dsh-document-render' export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise { signal.throwIfAborted() const entry = await lstat(path) - if (!entry.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') + if (!entry.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) try { const info = await file.stat() - if (!info.isFile()) throw new DocumentRenderError('invalid-output', 'The converter output is not a regular file.') - if (info.size > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (!info.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') + if (info.size > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const bytes = Buffer.alloc(info.size + 1) let length = 0 while (length < bytes.length) { @@ -28,11 +28,11 @@ export async function readPdf(path: string, limit: number, signal: AbortSignal): length += read.bytesRead } signal.throwIfAborted() - if (length > limit) throw new DocumentRenderError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (length > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const pdf = bytes.subarray(0, length) if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u) || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) { - throw new DocumentRenderError('invalid-output', 'The converter did not produce a complete PDF.') + throw new DocumentConvertError('invalid-output', 'The converter did not produce a complete PDF.') } return pdf } finally { await file.close() } diff --git a/packages/document/document-render-libreoffice/src/queue.ts b/packages/document/document-convert-libreoffice/src/queue.ts similarity index 82% rename from packages/document/document-render-libreoffice/src/queue.ts rename to packages/document/document-convert-libreoffice/src/queue.ts index affcebfb3f..6ed9b0f487 100644 --- a/packages/document/document-render-libreoffice/src/queue.ts +++ b/packages/document/document-convert-libreoffice/src/queue.ts @@ -1,34 +1,34 @@ /** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */ import { createHash } from 'node:crypto' -import { DocumentRenderError, DocumentRenderKey, type DocumentExtension, type DocumentRendererGeneration, - type DocumentRenderRequest, type DocumentRenderResult } from '@deepseek-ai/dsh-document-render' +import { DocumentConvertError, DocumentConvertKey, type DocumentExtension, type DocumentConverterGeneration, + type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' import type { Config } from './index.ts' -type Converted = Pick +type Converted = Pick type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise interface Reader { job: Job readonly source: string - readonly priority: DocumentRenderRequest['priority'] - readonly resolve: (value: DocumentRenderResult) => void + readonly priority: DocumentConvertRequest['priority'] + readonly resolve: (value: DocumentConvertResult) => void readonly reject: (error: unknown) => void readonly cleanup: () => void } interface Job { - readonly request: DocumentRenderRequest + readonly request: DocumentConvertRequest readonly controller: AbortController readonly readers: Set readonly sources: Set - priority: DocumentRenderRequest['priority'] + priority: DocumentConvertRequest['priority'] state: 'queued' | 'running' | 'finished' } -/** One renderer generation owns every queued source, conversion, reader, and retained PDF. */ -export class RenderQueue { - private readonly ready = new Map() - private readonly aliases = new Map() +/** One converter generation owns every queued source, conversion, reader, and retained PDF. */ +export class ConversionQueue { + private readonly ready = new Map() + private readonly aliases = new Map() private readonly sources = new Map() - private readonly digests = new Map() + private readonly digests = new Map() private readonly queue: Job[] = [] private readonly tasks = new Set>() private readonly jobs = new Set() @@ -44,7 +44,7 @@ export class RenderQueue { * @param generation - provider lifetime; prevents reuse after engine or font replacement. * @param convert - executes one admitted conversion and settles after scratch cleanup. */ - constructor(private readonly config: Config, private readonly generation: DocumentRendererGeneration, + constructor(private readonly config: Config, private readonly generation: DocumentConverterGeneration, private readonly convert: Convert) {} /** @@ -53,11 +53,11 @@ export class RenderQueue { * @param signal - this reader's cancellation; the final reader cancels shared work. * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early. */ - async read(request: DocumentRenderRequest, signal?: AbortSignal): Promise { + async read(request: DocumentConvertRequest, signal?: AbortSignal): Promise { signal?.throwIfAborted() if (this.disposed) throw this.unavailable() if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) { - throw new DocumentRenderError('input-too-large', 'The Office source exceeds maxInputBytes.') + throw new DocumentConvertError('input-too-large', 'The Office source exceeds maxInputBytes.') } const source = JSON.stringify([request.source.key, request.source.version, request.extension]) const alias = this.aliases.get(source) @@ -87,7 +87,7 @@ export class RenderQueue { const shared = job if (request.priority === 'foreground') shared.priority = 'foreground' this.readers++ - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const abort = (): void => { this.release(reader) const reason: unknown = signal?.reason @@ -114,9 +114,9 @@ export class RenderQueue { await Promise.allSettled(this.tasks) } - private busy(): DocumentRenderError { return new DocumentRenderError('busy', 'The document renderer has reached its admission limit.') } - private unavailable(): DocumentRenderError { return new DocumentRenderError('unavailable', 'The document renderer is unavailable.') } - private copy(result: DocumentRenderResult): DocumentRenderResult { + private busy(): DocumentConvertError { return new DocumentConvertError('busy', 'The document converter has reached its admission limit.') } + private unavailable(): DocumentConvertError { return new DocumentConvertError('unavailable', 'The document converter is unavailable.') } + private copy(result: DocumentConvertResult): DocumentConvertResult { return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] } } private release(reader: Reader): void { @@ -177,10 +177,10 @@ export class RenderQueue { signal.throwIfAborted() const input = await job.request.source.read(signal, reserved) signal.throwIfAborted() - if (input.version !== job.request.source.version) throw new DocumentRenderError('source-changed', 'The source changed while waiting for conversion.') - if (input.bytes.byteLength > reserved) throw new DocumentRenderError('input-too-large', 'The source exceeds its reserved read capacity.') + if (input.version !== job.request.source.version) throw new DocumentConvertError('source-changed', 'The source changed while waiting for conversion.') + if (input.bytes.byteLength > reserved) throw new DocumentConvertError('input-too-large', 'The source exceeds its reserved read capacity.') const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex') - const key = DocumentRenderKey(`${this.generation}:${digest}`) + const key = DocumentConvertKey(`${this.generation}:${digest}`) const cached = this.ready.get(key) if (cached !== undefined) { this.ready.delete(key) @@ -207,7 +207,7 @@ export class RenderQueue { } finally { if (this.digests.get(key) === job) this.digests.delete(key) } } - private finish(job: Job, result: DocumentRenderResult): void { + private finish(job: Job, result: DocumentConvertResult): void { for (const source of job.sources) this.sources.delete(source) if (this.ready.has(result.cacheKey)) { for (const source of job.sources) { @@ -218,10 +218,10 @@ export class RenderQueue { } for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) } } - private retain(result: DocumentRenderResult): void { + private retain(result: DocumentConvertResult): void { if (result.pdf.byteLength > this.config.maxCachedBytes) return while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) { - const [key, oldest] = this.ready.entries().next().value as [DocumentRenderKey, DocumentRenderResult] + const [key, oldest] = this.ready.entries().next().value as [DocumentConvertKey, DocumentConvertResult] this.ready.delete(key) this.cachedBytes -= oldest.pdf.byteLength for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source) diff --git a/packages/document/document-render-libreoffice/tests/output.spec.ts b/packages/document/document-convert-libreoffice/tests/output.spec.ts similarity index 100% rename from packages/document/document-render-libreoffice/tests/output.spec.ts rename to packages/document/document-convert-libreoffice/tests/output.spec.ts diff --git a/packages/document/document-render-libreoffice/tests/provider.spec.ts b/packages/document/document-convert-libreoffice/tests/provider.spec.ts similarity index 80% rename from packages/document/document-render-libreoffice/tests/provider.spec.ts rename to packages/document/document-convert-libreoffice/tests/provider.spec.ts index aa36e95d2c..d84e459ced 100644 --- a/packages/document/document-render-libreoffice/tests/provider.spec.ts +++ b/packages/document/document-convert-libreoffice/tests/provider.spec.ts @@ -1,22 +1,22 @@ /** Disk output, resource bounds, and cancellation around the external kit. */ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { DocumentSourceKey, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { Context } from '@deepseek-ai/cordis' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import LibreOfficeRenderer, { Config } from '../src/index.ts' +import LibreOfficeConverter, { Config } from '../src/index.ts' const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') const input = new Uint8Array([80, 75, 3, 4]) -const request: DocumentRenderRequest = { extension: 'docx', priority: 'foreground', source: { +const request: DocumentConvertRequest = { extension: 'docx', priority: 'foreground', source: { key: DocumentSourceKey('source'), version: 'v1', bytes: input.length, read: async () => ({ bytes: input, version: 'v1' }), } } -function distinct(index: number): DocumentRenderRequest { +function distinct(index: number): DocumentConvertRequest { return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`), read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } } } @@ -35,14 +35,14 @@ beforeEach(() => { }) afterEach(async () => { await ctx.fiber.dispose() }) -async function mount(config: Partial = {}): Promise { - await ctx.plugin(LibreOfficeRenderer, config) - return ctx.documentRender as LibreOfficeRenderer +async function mount(config: Partial = {}): Promise { + await ctx.plugin(LibreOfficeConverter, config) + return ctx.documentConvert as LibreOfficeConverter } it('preserves the kit font defaults when font configuration is omitted', async () => { - const renderer = await mount() - await renderer.render(request) + const provider = await mount() + await provider.convert(request) expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontDirectories') expect(kit.create.mock.calls[0]![0]).not.toHaveProperty('fontFallbacks') expect(Config({ fontDirectories: [] }).fontDirectories).toEqual([]) @@ -65,7 +65,7 @@ it('passes configured limits to the kit, reuses the converter, and removes all s maxImageResolution: 144, maxArchiveEntries: 32, maxUncompressedBytes: 4096, timeoutMs: 321, fontDirectories: [], fontFallbacks: [['sans-serif', 'Arial'], ['serif', 'Times New Roman']], maxFontFiles: 12, maxFontFileBytes: 4096, maxLoadedFontBytes: 8192 }) - const renderer = await mount(config) + const provider = await mount(config) let scratch = '' render.mockImplementation(async ({ inputPath, outputPath }) => { scratch = dirname(inputPath) @@ -77,10 +77,10 @@ it('passes configured limits to the kit, reuses the converter, and removes all s await writeFile(outputPath, pdf) return { backend: 'native', missingFonts: ['Missing Serif'] } }) - const result = await renderer.render(request) + const result = await provider.convert(request) expect(result).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) - await renderer.render(request) + await provider.convert(request) const { maxConcurrentConversions: _count, maxQueuedJobs: _queued, maxReaders: _readers, maxSourceBytes: _source, maxBackgroundConversions: _background, maxCachedEntries: _entries, maxCachedBytes: _cached, maxSourceEntries: _aliases, ...options } = config @@ -91,13 +91,13 @@ it('passes configured limits to the kit, reuses the converter, and removes all s }) it('refuses an oversized input before allocating a converter', async () => { - const renderer = await mount({ maxInputBytes: 3 }) - await expect(renderer.render(request)).rejects.toMatchObject({ code: 'input-too-large' }) + const provider = await mount({ maxInputBytes: 3 }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: 'input-too-large' }) expect(kit.create).not.toHaveBeenCalled() }) it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const)('rejects %s kit output and removes its directory', async (kind) => { - const renderer = await mount({ maxOutputBytes: pdf.length }) + const provider = await mount({ maxOutputBytes: pdf.length }) let scratch = '' render.mockImplementation(async ({ inputPath, outputPath }) => { scratch = dirname(inputPath) @@ -107,31 +107,31 @@ it.each(['missing', 'directory', 'not-pdf', 'incomplete', 'too-large'] as const) else if (kind === 'too-large') await writeFile(outputPath, Buffer.concat([pdf, pdf])) return { backend: 'wasm', missingFonts: [] } }) - await expect(renderer.render(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: kind === 'too-large' ? 'output-too-large' : 'invalid-output' }) await expect(access(scratch)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('retries initialization after a failed kit factory and reports unclassified errors', async () => { - const renderer = await mount() + const provider = await mount() kit.create.mockRejectedValueOnce(Object.assign(new Error('absent asset'), { code: 'unavailable' })) - await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) - expect(await renderer.render(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: 'unavailable' }) + expect(await provider.convert(request)).toMatchObject({ pdf: Uint8Array.from(pdf), missingFonts: ['Missing Serif'] }) expect(kit.create).toHaveBeenCalledTimes(2) render.mockRejectedValueOnce(new Error('unexpected engine failure')) - await expect(renderer.render(distinct(9))).rejects.toMatchObject({ code: 'failed' }) + await expect(provider.convert(distinct(9))).rejects.toMatchObject({ code: 'failed' }) }) it.each([ 'input-too-large', 'output-too-large', 'invalid-document', 'unsupported-format', 'invalid-output', 'timeout', 'unavailable', ] as const)('preserves the kit %s failure for the document consumer', async (code) => { - const renderer = await mount() + const provider = await mount() const cause = Object.assign(new Error('conversion failed'), { code }) render.mockRejectedValueOnce(cause) - await expect(renderer.render(request)).rejects.toMatchObject({ code, cause }) + await expect(provider.convert(request)).rejects.toMatchObject({ code, cause }) }) it('cancels a queued caller without starting or stopping another conversion', async () => { - const renderer = await mount({ maxConcurrentConversions: 1 }) + const provider = await mount({ maxConcurrentConversions: 1 }) const entered = Promise.withResolvers() const release = Promise.withResolvers() render.mockImplementationOnce(async ({ outputPath }, signal) => { @@ -140,11 +140,11 @@ it('cancels a queued caller without starting or stopping another conversion', as await writeFile(outputPath, pdf) return { backend: 'wasm', missingFonts: [] } }) - const first = renderer.render(request) + const first = provider.convert(request) try { const activeSignal = await entered.promise const controller = new AbortController() - const second = renderer.render(request, controller.signal) + const second = provider.convert(request, controller.signal) const rejected = expect(second).rejects.toMatchObject({ name: 'AbortError' }) await Promise.resolve(undefined) controller.abort() @@ -152,11 +152,11 @@ it('cancels a queued caller without starting or stopping another conversion', as expect(render).toHaveBeenCalledOnce() expect(activeSignal.aborted).toBe(false) } finally { release.resolve(undefined); await first } - expect(await renderer.render(request)).toHaveProperty('pdf') + expect(await provider.convert(request)).toHaveProperty('pdf') }) it('bounds active converters and resumes queued work when a slot becomes free', async () => { - const renderer = await mount({ maxConcurrentConversions: 2 }) + const provider = await mount({ maxConcurrentConversions: 2 }) const both = Promise.withResolvers() const release = Promise.withResolvers() let calls = 0 @@ -166,7 +166,7 @@ it('bounds active converters and resumes queued work when a slot becomes free', await writeFile(outputPath, pdf) return { backend: 'native', missingFonts: [] } }) - const work = [renderer.render(distinct(1)), renderer.render(distinct(2)), renderer.render(distinct(3))] + const work = [provider.convert(distinct(1)), provider.convert(distinct(2)), provider.convert(distinct(3))] try { await both.promise expect(kit.create).toHaveBeenCalledTimes(2) @@ -177,7 +177,7 @@ it('bounds active converters and resumes queued work when a slot becomes free', }) it.each(['caller', 'provider'] as const)('joins late kit completion and scratch cleanup after %s cancellation', async (owner) => { - const renderer = await mount() + const provider = await mount() const entered = Promise.withResolvers<{ signal: AbortSignal; scratch: string }>() const release = Promise.withResolvers() render.mockImplementation(async ({ inputPath, outputPath }, signal) => { @@ -187,7 +187,7 @@ it.each(['caller', 'provider'] as const)('joins late kit completion and scratch return { backend: 'native', missingFonts: [] } }) const caller = new AbortController() - const work = renderer.render(request, caller.signal) + const work = provider.convert(request, caller.signal) const rejected = expect(work).rejects.toMatchObject(owner === 'caller' ? { name: 'AbortError' } : { code: 'unavailable' }) const { signal, scratch } = await entered.promise let settled = false @@ -207,11 +207,11 @@ it.each(['caller', 'provider'] as const)('joins late kit completion and scratch }) it('joins initialization during disposal and never starts a render after cancellation', async () => { - const renderer = await mount() + const provider = await mount() const entered = Promise.withResolvers() const release = Promise.withResolvers() kit.create.mockImplementationOnce(() => { entered.resolve(undefined); return release.promise }) - const work = renderer.render(request) + const work = provider.convert(request) const rejected = expect(work).rejects.toMatchObject({ code: 'unavailable' }) await entered.promise const closing = ctx.fiber.dispose() @@ -220,29 +220,29 @@ it('joins initialization during disposal and never starts a render after cancell await closing expect(render).not.toHaveBeenCalled() expect(dispose).toHaveBeenCalledOnce() - await expect(renderer.render(request)).rejects.toMatchObject({ code: 'unavailable' }) + await expect(provider.convert(request)).rejects.toMatchObject({ code: 'unavailable' }) }) it('rejects caller cancellation before allocating any conversion resources', async () => { - const renderer = await mount() + const provider = await mount() const reason = new Error('caller cancelled') - await expect(renderer.render(request, AbortSignal.abort(reason))).rejects.toBe(reason) + await expect(provider.convert(request, AbortSignal.abort(reason))).rejects.toBe(reason) expect(kit.create).not.toHaveBeenCalled() }) it('rejects relative font directories during provider configuration', async () => { - expect(() => new LibreOfficeRenderer(ctx, Config({ fontDirectories: ['relative/fonts'] }))) + expect(() => new LibreOfficeConverter(ctx, Config({ fontDirectories: ['relative/fonts'] }))) .toThrow('fontDirectories must contain absolute paths') }) it('rejects source capacity below one permitted input before allocating a converter', () => { - expect(() => new LibreOfficeRenderer(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) + expect(() => new LibreOfficeConverter(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) .toThrow('maxSourceBytes must be at least maxInputBytes') expect(kit.create).not.toHaveBeenCalled() }) it('joins every converter disposal before reporting an engine cleanup failure', async () => { - const renderer = await mount({ maxConcurrentConversions: 2 }) + const provider = await mount({ maxConcurrentConversions: 2 }) const entered = Promise.withResolvers() const released = Promise.withResolvers() const cleanupEntered = Promise.withResolvers() @@ -258,7 +258,7 @@ it('joins every converter disposal before reporting an engine cleanup failure', kit.create.mockReset() .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { throw failed } }) .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } }) - const work = [renderer.render(distinct(1)), renderer.render(distinct(2))] + const work = [provider.convert(distinct(1)), provider.convert(distinct(2))] try { await entered.promise } finally { released.resolve(undefined); await Promise.all(work) } let disposed = false const closing = ctx.fiber.dispose().then(() => { disposed = true }) diff --git a/packages/document/document-render-libreoffice/tests/queue.spec.ts b/packages/document/document-convert-libreoffice/tests/queue.spec.ts similarity index 96% rename from packages/document/document-render-libreoffice/tests/queue.spec.ts rename to packages/document/document-convert-libreoffice/tests/queue.spec.ts index 9ac47223d5..ced4a3a646 100644 --- a/packages/document/document-render-libreoffice/tests/queue.spec.ts +++ b/packages/document/document-convert-libreoffice/tests/queue.spec.ts @@ -1,18 +1,18 @@ /** Controlled source and engine completions exercise admission and shared content ownership. */ -import { DocumentSourceKey, DocumentRendererGeneration, type DocumentRenderRequest } from '@deepseek-ai/dsh-document-render' +import { DocumentSourceKey, DocumentConverterGeneration, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { expect, it, onTestFinished, vi } from 'vitest' -import { RenderQueue } from '../src/queue.ts' +import { ConversionQueue } from '../src/queue.ts' import { Config } from '../src/index.ts' const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] } -function source(key: string, byte = 1, priority: DocumentRenderRequest['priority'] = 'foreground') { - const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) - const request: DocumentRenderRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } +function source(key: string, byte = 1, priority: DocumentConvertRequest['priority'] = 'foreground') { + const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) + const request: DocumentConvertRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } return { read, request } } function harness(config: Partial = {}) { - const convert = vi.fn[2]>().mockResolvedValue(output) - const queue = new RenderQueue(Config(config), DocumentRendererGeneration('test'), convert) + const convert = vi.fn[2]>().mockResolvedValue(output) + const queue = new ConversionQueue(Config(config), DocumentConverterGeneration('test'), convert) onTestFinished(() => queue.dispose()) return { queue, convert } } @@ -290,7 +290,7 @@ it('keeps a synchronous abort replacement shareable after the old conversion set const h = harness({ maxConcurrentConversions: 2 }) const firstEntered = Promise.withResolvers(), firstComplete = Promise.withResolvers() const replacementEntered = Promise.withResolvers(), replacementComplete = Promise.withResolvers() - const replacementSpawned = Promise.withResolvers<{ work: ReturnType }>() + const replacementSpawned = Promise.withResolvers<{ work: ReturnType }>() const a = source('a'), caller = new AbortController() const pending: Promise[] = [] h.convert.mockImplementationOnce((_bytes, _extension, signal) => { @@ -332,11 +332,11 @@ it('keeps a synchronous abort replacement shareable after the old conversion set } }) -it('separates Office extensions and renderer generations in content identity', async () => { +it('separates Office extensions and converter generations in content identity', async () => { const h = harness(), a = source('a') const first = await h.queue.read(a.request) const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' }) - const other = new RenderQueue(Config({}), DocumentRendererGeneration('replacement'), h.convert) + const other = new ConversionQueue(Config({}), DocumentConverterGeneration('replacement'), h.convert) onTestFinished(() => other.dispose()) const replacement = await other.read(a.request) expect(first.cacheKey).not.toBe(otherFormat.cacheKey) diff --git a/packages/document/document-render-libreoffice/tsconfig.json b/packages/document/document-convert-libreoffice/tsconfig.json similarity index 89% rename from packages/document/document-render-libreoffice/tsconfig.json rename to packages/document/document-convert-libreoffice/tsconfig.json index 8e81caaa59..2199a71798 100644 --- a/packages/document/document-render-libreoffice/tsconfig.json +++ b/packages/document/document-convert-libreoffice/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../document-render" + "path": "../document-convert" } ] } diff --git a/packages/document/document-render/README.i18n.yaml b/packages/document/document-convert/README.i18n.yaml similarity index 69% rename from packages/document/document-render/README.i18n.yaml rename to packages/document/document-convert/README.i18n.yaml index 1fff641553..b0aec675b0 100644 --- a/packages/document/document-render/README.i18n.yaml +++ b/packages/document/document-convert/README.i18n.yaml @@ -1,6 +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/document/document-render/README.md -README.md: ead6c62b25dcca326ad362af4e28a6431472f55c -README.zh.md: 45138e306a367833e0acade97b7d13f46fdcefa8 +# pnpm run verify-translation-pairing --write packages/document/document-convert/README.md +README.md: fbc98feef30c4e32c255b82f57ea1aad5a68940d +README.zh.md: 3fa18652036d4823d45e3ccc38d03f599b3c4326 diff --git a/packages/document/document-render/README.md b/packages/document/document-convert/README.md similarity index 69% rename from packages/document/document-render/README.md rename to packages/document/document-convert/README.md index ead6c62b25..fbc98feef3 100644 --- a/packages/document/document-render/README.md +++ b/packages/document/document-convert/README.md @@ -3,7 +3,7 @@ description: "Authorized Office input and complete PDF output for Host conversio kind: "package-reference" --- -# @deepseek-ai/dsh-document-render +# @deepseek-ai/dsh-document-convert English | [中文](README.zh.md) @@ -25,7 +25,7 @@ Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes t ## Use this package -Mount the [LibreOffice provider](../document-render-libreoffice/README.md) to supply `ctx.documentRender`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `render()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. +Mount the [LibreOffice provider](../document-convert-libreoffice/README.md) to supply `ctx.documentConvert`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `convert()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. ----- @@ -35,7 +35,7 @@ Mount the [LibreOffice provider](../document-render-libreoffice/README.md) to su
Implementation internals — click to expand -Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentRenderError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentRenderError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation. +Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentConvertError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentConvertError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation.
@@ -44,7 +44,7 @@ Providers return independent caller-owned PDF bytes, a conversion cache key, and ## Further Exploration -- [Document rendering](../../../docs/subsystems/document-render.md) — composition and ownership. +- [Document conversion](../../../docs/subsystems/document-convert.md) — composition and ownership. - [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. ----- diff --git a/packages/document/document-render/README.zh.md b/packages/document/document-convert/README.zh.md similarity index 67% rename from packages/document/document-render/README.zh.md rename to packages/document/document-convert/README.zh.md index 45138e306a..3fa1865203 100644 --- a/packages/document/document-render/README.zh.md +++ b/packages/document/document-convert/README.zh.md @@ -3,7 +3,7 @@ description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF kind: "package-reference" --- -# @deepseek-ai/dsh-document-render +# @deepseek-ai/dsh-document-convert [English](README.md) | 中文 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -挂载 [LibreOffice 提供方](../document-render-libreoffice/README.zh.md)以提供 `ctx.documentRender`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `render()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 +挂载 [LibreOffice 提供方](../document-convert-libreoffice/README.zh.md)以提供 `ctx.documentConvert`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `convert()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 ----- @@ -35,7 +35,7 @@ kind: "package-reference"
实现内部细节 — 点击展开 -提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和渲染 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentRenderError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentRenderError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。 +提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和转换 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentConvertError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentConvertError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。
@@ -44,7 +44,7 @@ kind: "package-reference" ## 延伸阅读 -- [文档渲染](../../../docs/subsystems/document-render.zh.md) — 组合和所有权。 +- [文档转换](../../../docs/subsystems/document-convert.zh.md) — 组合和所有权。 - [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 ----- diff --git a/packages/document/document-render/package.json b/packages/document/document-convert/package.json similarity index 90% rename from packages/document/document-render/package.json rename to packages/document/document-convert/package.json index 741ffca7db..bb7805f3cc 100644 --- a/packages/document/document-render/package.json +++ b/packages/document/document-convert/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-document-render", + "name": "@deepseek-ai/dsh-document-convert", "description": "Authorized Office bytes to PDF rendering service", "version": "0.1.6-alpha.1", "publishConfig": { @@ -12,7 +12,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-render" + "directory": "packages/document/document-convert" }, "exports": { ".": { diff --git a/packages/document/document-convert/src/index.ts b/packages/document/document-convert/src/index.ts new file mode 100644 index 0000000000..014e71c280 --- /dev/null +++ b/packages/document/document-convert/src/index.ts @@ -0,0 +1,44 @@ +/** Host document conversion: authorized Office bytes become a complete PDF. */ +import { Context, Service } from '@deepseek-ai/cordis' +import type { DocumentConvertErrorCode, DocumentConvertRequest, DocumentConvertResult, DocumentConverterGeneration } from './types.ts' + +export * from './types.ts' + +/** Classified conversion failure; engine details stay in the cause. */ +export class DocumentConvertError extends Error { + /** + * @param code - category suitable for a conversion consumer. + * @param message - diagnostic explaining the failed conversion. + * @param options - underlying engine or filesystem failure. + */ + constructor(readonly code: DocumentConvertErrorCode, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'DocumentConvertError' + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** One Host converter for already-authorized Office bytes. */ + documentConvert: DocumentConverter + } +} + +/** Load one provider subclass per context; consumers own source authorization. */ +export abstract class DocumentConverter extends Service { + constructor(ctx: Context) { super(ctx, 'documentConvert') } + + /** Changes whenever engine, font, or conversion configuration is replaced. */ + abstract readonly generation: DocumentConverterGeneration + + /** + * Convert Office bytes without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ + abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise +} + +export default DocumentConverter diff --git a/packages/document/document-render/src/types.ts b/packages/document/document-convert/src/types.ts similarity index 77% rename from packages/document/document-render/src/types.ts rename to packages/document/document-convert/src/types.ts index ce23ae3cb6..35f63911ac 100644 --- a/packages/document/document-render/src/types.ts +++ b/packages/document/document-convert/src/types.ts @@ -14,32 +14,32 @@ export type DocumentSourceKey = Branded<'DocumentSourceKey'> export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString(key) } /** One provider lifetime, including its engine, rendering settings, and font configuration. */ -export type DocumentRendererGeneration = Branded<'DocumentRendererGeneration'> +export type DocumentConverterGeneration = Branded<'DocumentConverterGeneration'> /** * Label a provider lifetime. * @param value - unique generation created by the provider. - * @returns branded renderer generation. + * @returns branded converter generation. */ -export function DocumentRendererGeneration(value: string): DocumentRendererGeneration { - return brandString(value) +export function DocumentConverterGeneration(value: string): DocumentConverterGeneration { + return brandString(value) } /** Provider generation and source-content digest; consumers must not parse it. */ -export type DocumentRenderKey = Branded<'DocumentRenderKey'> +export type DocumentConvertKey = Branded<'DocumentConvertKey'> /** - * Label a renderer-owned content identity. + * Label a converter-owned content identity. * @param value - generation and content identity created by the provider. * @returns branded conversion identity. */ -export function DocumentRenderKey(value: string): DocumentRenderKey { return brandString(value) } +export function DocumentConvertKey(value: string): DocumentConvertKey { return brandString(value) } /** Foreground previews and explicit QA precede speculative background conversion. */ -export type DocumentRenderPriority = 'foreground' | 'background' +export type DocumentConvertPriority = 'foreground' | 'background' /** Source authorization and metadata lookup must finish before submitting a request. */ -export interface DocumentRenderRequest { +export interface DocumentConvertRequest { readonly extension: DocumentExtension - readonly priority: DocumentRenderPriority + readonly priority: DocumentConvertPriority readonly source: { readonly key: DocumentSourceKey readonly version: string @@ -56,16 +56,16 @@ export interface DocumentRenderRequest { } /** Successful conversion; failed and interrupted conversions reject instead. */ -export interface DocumentRenderResult { +export interface DocumentConvertResult { /** Caller-owned complete PDF, valid after provider disposal. */ readonly pdf: Uint8Array /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ readonly missingFonts: string[] - readonly cacheKey: DocumentRenderKey - readonly generation: DocumentRendererGeneration + readonly cacheKey: DocumentConvertKey + readonly generation: DocumentConverterGeneration } /** Failures a conversion consumer can present without exposing engine diagnostics. */ -export type DocumentRenderErrorCode = +export type DocumentConvertErrorCode = | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-render/tsconfig.json b/packages/document/document-convert/tsconfig.json similarity index 100% rename from packages/document/document-render/tsconfig.json rename to packages/document/document-convert/tsconfig.json diff --git a/packages/document/document-render/src/index.ts b/packages/document/document-render/src/index.ts deleted file mode 100644 index 49d121945e..0000000000 --- a/packages/document/document-render/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Host document rendering: authorized Office bytes become a complete PDF. */ -import { Context, Service } from '@deepseek-ai/cordis' -import type { DocumentRenderErrorCode, DocumentRenderRequest, DocumentRenderResult, DocumentRendererGeneration } from './types.ts' - -export * from './types.ts' - -/** Classified conversion failure; engine details stay in the cause. */ -export class DocumentRenderError extends Error { - /** - * @param code - category suitable for a conversion consumer. - * @param message - diagnostic explaining the failed conversion. - * @param options - underlying engine or filesystem failure. - */ - constructor(readonly code: DocumentRenderErrorCode, message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'DocumentRenderError' - } -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** One Host renderer for already-authorized Office bytes. */ - documentRender: DocumentRenderer - } -} - -/** Load one provider subclass per context; consumers own source authorization. */ -export abstract class DocumentRenderer extends Service { - constructor(ctx: Context) { super(ctx, 'documentRender') } - - /** Changes whenever engine, font, or rendering configuration is replaced. */ - abstract readonly generation: DocumentRendererGeneration - - /** - * Convert Office bytes without modifying the source or writing Session events. - * @param request - authorized metadata and deferred bounded source read. - * @param signal - caller cancellation; provider disposal also stops active work. - * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. - */ - abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise -} - -export default DocumentRenderer diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts index a871627f38..7c76b2aea4 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts @@ -3,7 +3,7 @@ import { notAvailableError } from '../notImplementedFail.ts' /** * Refuse converter creation with the kit's unavailable error code. - * @returns A rejected promise consumed by the Host document-render provider. + * @returns A rejected promise consumed by the Host document-convert provider. */ export function createConverter(): Promise { return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6afe5966a7..446ca88582 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -875,21 +875,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ ], }, { - key: 'documentRender', + key: 'documentConvert', summary: 'Load one provider subclass per context; consumers own source authorization.', description: 'Load one provider subclass per context; consumers own source authorization.', methods: [ { - signature: 'abstract readonly generation: DocumentRendererGeneration', - description: 'Changes whenever engine, font, or rendering configuration is replaced.', + signature: 'abstract readonly generation: DocumentConverterGeneration', + description: 'Changes whenever engine, font, or conversion configuration is replaced.', parameters: [], }, { - signature: 'abstract render(request: DocumentRenderRequest, signal?: AbortSignal): Promise', + signature: 'abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise', description: 'Convert Office bytes without modifying the source or writing Session events.', parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', - throws: ['{DocumentRenderError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], + throws: ['{DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], }, ], }, @@ -4473,30 +4473,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryRegistrationHandle', declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', }, + { + name: 'DocumentConverterGeneration', + declaration: 'export type DocumentConverterGeneration = Branded<\'DocumentConverterGeneration\'>;', + }, + { + name: 'DocumentConvertKey', + declaration: 'export type DocumentConvertKey = Branded<\'DocumentConvertKey\'>;', + }, + { + name: 'DocumentConvertPriority', + declaration: 'export type DocumentConvertPriority = \'foreground\' | \'background\';', + }, + { + name: 'DocumentConvertRequest', + declaration: 'export interface DocumentConvertRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentConvertPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', + }, + { + name: 'DocumentConvertResult', + declaration: 'export interface DocumentConvertResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentConvertKey;\n readonly generation: DocumentConverterGeneration;\n}', + }, { name: 'DocumentExtension', declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', }, - { - name: 'DocumentRendererGeneration', - declaration: 'export type DocumentRendererGeneration = Branded<\'DocumentRendererGeneration\'>;', - }, - { - name: 'DocumentRenderKey', - declaration: 'export type DocumentRenderKey = Branded<\'DocumentRenderKey\'>;', - }, - { - name: 'DocumentRenderPriority', - declaration: 'export type DocumentRenderPriority = \'foreground\' | \'background\';', - }, - { - name: 'DocumentRenderRequest', - declaration: 'export interface DocumentRenderRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentRenderPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', - }, - { - name: 'DocumentRenderResult', - declaration: 'export interface DocumentRenderResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentRenderKey;\n readonly generation: DocumentRendererGeneration;\n}', - }, { name: 'DocumentSourceKey', declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10bb66ca6b..bea1475a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2223,12 +2223,12 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner - '@deepseek-ai/dsh-document-render': + '@deepseek-ai/dsh-document-convert': specifier: workspace:^ - version: link:../../document/document-render - '@deepseek-ai/dsh-document-render-libreoffice': + version: link:../../document/document-convert + '@deepseek-ai/dsh-document-convert-libreoffice': specifier: workspace:^ - version: link:../../document/document-render-libreoffice + version: link:../../document/document-convert-libreoffice '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -5960,7 +5960,7 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/document/document-render: + packages/document/document-convert: dependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5970,7 +5970,7 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis - packages/document/document-render-libreoffice: + packages/document/document-convert-libreoffice: dependencies: '@deepseek-ai/libreoffice-kit': specifier: 0.0.1 @@ -5988,9 +5988,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-document-render': + '@deepseek-ai/dsh-document-convert': specifier: workspace:^ - version: link:../document-render + version: link:../document-convert packages/experimental/agent-team: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 07b0dc583c..47a0f41b83 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,7 +63,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', - documentRender: 'document-render.md', + documentConvert: 'document-convert.md', attachments: 'attachment.md', shell: 'shell.md', shellEnv: 'shell.md', @@ -277,10 +277,10 @@ export const LINK_MAP: Readonly> = { PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', - DocumentRenderRequest: 'document-render.md', - DocumentRenderResult: 'document-render.md', - DocumentRenderPriority: 'document-render.md', - DocumentRendererGeneration: 'document-render.md', + DocumentConvertRequest: 'document-convert.md', + DocumentConvertResult: 'document-convert.md', + DocumentConvertPriority: 'document-convert.md', + DocumentConverterGeneration: 'document-convert.md', Agent: 'core.md', AgentCancelCause: 'core.md', AgentFactory: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d60798a00d..fbe5d3ec86 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -151,8 +151,8 @@ const SERVICE_ROLES: ServiceRole[] = [ note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.', }, { - key: 'documentRender', pkg: 'document-render', title: 'Office to PDF rendering', - mode: 'seam', implementations: ['document-render-libreoffice'], consumers: [], + key: 'documentConvert', pkg: 'document-convert', title: 'Office to PDF conversion', + mode: 'seam', implementations: ['document-convert-libreoffice'], consumers: [], note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6d3cec5129..41cdfa3410 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -53,8 +53,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' }, 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, - 'packages/document/document-render': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, - 'packages/document/document-render-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, + 'packages/document/document-convert': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, + 'packages/document/document-convert-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ea876af450..f47fdc83c7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-experimental-browser-use-stagehand-native": ["./packages/experimental/browser-use-stagehand-native/src/index.ts"], "@deepseek-ai/dsh-experimental-browser-use-runtime/mcp": ["./packages/experimental/browser-use-runtime/src/mcp.ts"], + "@deepseek-ai/dsh-document-convert/types": ["./packages/document/document-convert/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], @@ -325,8 +326,8 @@ "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], - "@deepseek-ai/dsh-document-render": ["./packages/document/document-render/src"], - "@deepseek-ai/dsh-document-render-libreoffice": ["./packages/document/document-render-libreoffice/src"], + "@deepseek-ai/dsh-document-convert": ["./packages/document/document-convert/src"], + "@deepseek-ai/dsh-document-convert-libreoffice": ["./packages/document/document-convert-libreoffice/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 5367222539..88c2000975 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -160,8 +160,8 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, - { "path": "./packages/document/document-render" }, - { "path": "./packages/document/document-render-libreoffice" }, + { "path": "./packages/document/document-convert" }, + { "path": "./packages/document/document-convert-libreoffice" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From a69639a20088c80da8600fb4188337acb6e028f3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 14:55:38 +0800 Subject: [PATCH 3/9] fix(document): select Office engines from declared native targets --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +-- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- ...26-09-15-platform-office-engines.i18n.yaml | 4 +-- .../2026-09-15-platform-office-engines.md | 10 +++---- .../2026-09-15-platform-office-engines.zh.md | 10 +++---- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/prepare-dsh.ts | 6 ++-- apps/desktop/scripts/runtime-file-policy.ts | 6 ++-- .../desktop/tests/runtime-file-policy.spec.ts | 29 ++++++++++++++----- docs/capability-seams.i18n.yaml | 4 +-- docs/capability-seams.md | 2 +- docs/capability-seams.zh.md | 2 +- docs/subsystems/document-convert.i18n.yaml | 4 +-- docs/subsystems/document-convert.md | 2 +- docs/subsystems/document-convert.zh.md | 2 +- packages/document/README.i18n.yaml | 4 +-- packages/document/README.md | 2 +- packages/document/README.zh.md | 2 +- .../README.i18n.yaml | 4 +-- .../document-convert-libreoffice/README.md | 4 +-- .../document-convert-libreoffice/README.zh.md | 4 +-- .../document-convert-libreoffice/package.json | 2 +- python/sdk-runtime/README.i18n.yaml | 4 +-- python/sdk-runtime/README.md | 2 +- python/sdk-runtime/README.zh.md | 2 +- python/sdk-runtime/hatch_build.py | 13 ++++++--- .../src/deepseek_harness_runtime/__init__.py | 22 +++++++++----- python/sdk/tests/test_release_version.py | 5 +++- python/sdk/tests/test_runtime_resolution.py | 24 +++++++++++---- .../build-exe-for-python-sdk-office.spec.ts | 22 ++++++++------ scripts/build-exe-for-python-sdk-office.ts | 11 ++++--- scripts/build-python-release.py | 4 ++- scripts/gen-doc-graphs.ts | 2 +- scripts/libreoffice-engine.spec.ts | 24 +++++++++++++++ scripts/libreoffice-engine.ts | 17 +++++++++++ scripts/smoke-python-runtime.py | 6 +++- 39 files changed, 187 insertions(+), 90 deletions(-) create mode 100644 scripts/libreoffice-engine.spec.ts create mode 100644 scripts/libreoffice-engine.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 2550d722da..d12179dde4 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 45191be901f8df048d48798158c83fdfd8134737 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: b76035e6c81841e5eda4dcbcd575479996eff4b9 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 45191be901..56c9770966 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -72,7 +72,7 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. -The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (WASM on Linux, native on macOS/Windows), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. +The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index b76035e6c8..04351105a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -72,7 +72,7 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 -安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(Linux 为 WASM,macOS/Windows 为原生),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 +安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml index 8fd429f5bf..e10cadf677 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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-15-platform-office-engines.md -2026-09-15-platform-office-engines.md: fa8656eb7505b599f76640077f9b957fd867269c -2026-09-15-platform-office-engines.zh.md: eed1879b9a0bd5fe5056670c414c4db4b81d9d9b +2026-09-15-platform-office-engines.md: a3d1365bf33006c31d53c54f1255f72c4663cb5d +2026-09-15-platform-office-engines.zh.md: dfffd1b9de33a2b138e0450f8a60d940fadcefd4 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md index fa8656eb75..a3d1365bf3 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md @@ -10,20 +10,20 @@ Installing WASM beside a usable native engine adds a second LibreOffice payload ## Decision -Harness depends on the kit API and uses its platform-filtered optional engine dependencies. macOS and Windows install their matching ARM64 or x64 native package; Linux installs the shared WASM package. WASM declares Linux as its npm OS. The provider has no direct WASM dependency. Missing native engines on macOS/Windows reject converter creation instead of selecting WASM. +Harness selects one engine from the installed kit API’s `optionalDependencies`. A declared `@deepseek-ai/libreoffice-kit-${platform}-${arch}` requires that native package; other targets require the shared WASM package. The supported native target set belongs to the kit release, not an operating-system branch in Harness. A missing declared native package is an incomplete installation and never selects WASM. The provider has no direct WASM dependency. -Python sidecar assembly copies only the target engine and its dependency closure. Wheel packaging and runtime lookup require that engine; the relocated conversion smoke checks the selected backend. Desktop carries the ordinary npm installation in application resources. Engine compilation, qualification, and publication remain in the kit repository. +Python sidecar assembly copies only the selected engine and its dependency closure. Wheel packaging and runtime lookup select from the same kit manifest; the relocated conversion smoke checks the selected backend. Desktop filters its npm installation to that engine before signing and integrity sealing. Engine compilation, resolver compatibility, npm platform metadata, qualification, and publication remain in the kit repository. ## Alternatives considered -**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every macOS/Windows installation. Fixed-platform distributions require their native engine to be present and tested instead. +**Keep WASM beside every native engine.** This tolerates a missing optional native package but increases every distribution with native support. Fixed-platform distributions require their declared native engine to be present and tested instead. -**Remove WASM on every platform.** Linux has no released native engine in this kit family. Keeping the shared WASM package on Linux preserves Office conversion without introducing a new native release target. +**Remove WASM on every platform.** Targets without a released native engine still need conversion. The shared WASM package serves those targets without introducing a new native release target. **Make the Python Office sidecar optional.** The runtime carries the shared `dsh` CLI and its Web profile as well as the default SDK profile. Requiring the target engine gives the installed wheel a complete shipped profile set and reports an incomplete payload before launch. SDK and headless users also pay the engine download and installed-size cost. ## Consequences -macOS/Windows distributions omit the WASM download and expanded assets. A missing native package becomes an installation defect that must be repaired; conversion does not download an engine. Linux retains its WASM resource and font requirements. The kit’s engine-selection tests and actual npm installation tests cover platform selection; Harness sidecar, wheel, and runtime-resolution tests cover packaging requirements. New package bytes require kit qualification and matching dependency integrity records before publication. +Distributions with a declared native target omit WASM assets. Other targets retain WASM resource and font requirements. The pinned kit declares macOS/Windows ARM64 and x64 native packages, so current Linux distributions select WASM; a kit release can add a native Linux target without changing Harness’s selection rule. This does not expand Harness’s supported release platforms. Harness sidecar, wheel, and runtime-resolution tests cover both declared native targets and WASM selection, including missing native packages. New package bytes require kit qualification and matching dependency integrity records before publication. The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility. diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md index eed1879b9a..dfffd1b9de 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md @@ -10,20 +10,20 @@ Status: implemented ## Decision -Harness 依赖 kit API,并使用它按平台筛选的可选引擎依赖。macOS 和 Windows 安装匹配的 ARM64 或 x64 原生包;Linux 安装共享 WASM 包。WASM 的 npm OS 声明为 Linux。provider 不直接依赖 WASM。macOS/Windows 缺少原生引擎时拒绝创建转换器,不会选择 WASM。 +Harness 从已安装 kit API 的 `optionalDependencies` 中选择一个引擎。声明了 `@deepseek-ai/libreoffice-kit-${platform}-${arch}` 的目标要求该原生包,其余目标要求共享 WASM 包。受支持的原生目标集合由 kit 发布版本决定,不由 Harness 中的操作系统分支决定。已声明的原生包缺失表示安装不完整,不会改选 WASM。provider 不直接依赖 WASM。 -Python sidecar 组装仅复制目标引擎及其依赖闭包。wheel 打包和运行时查找要求该引擎存在;迁移后的转换冒烟检查所选 backend。Desktop 在应用资源中携带常规 npm 安装结果。引擎编译、资格验证和发布仍由 kit 仓库负责。 +Python sidecar 组装仅复制所选引擎及其依赖闭包。wheel 打包和运行时查找依据同一份 kit 清单选择;迁移目录后的转换冒烟检查所选后端。Desktop 在签名和完整性封装前将 npm 安装结果过滤为该引擎。引擎编译、解析器兼容性、npm 平台元数据、资格验证与发布仍由 kit 仓库负责。 ## Alternatives considered -**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个 macOS/Windows 安装。固定平台分发物改为要求其原生引擎存在且经过测试。 +**在每个原生引擎旁保留 WASM。** 这能容忍可选原生包缺失,却会增大每个支持原生引擎的分发物。固定平台分发物要求其已声明的原生引擎存在且经过测试。 -**在所有平台移除 WASM。** 当前 kit 家族没有已发布的 Linux 原生引擎。在 Linux 保留共享 WASM 包,既能继续转换 Office 文档,也无需引入新的原生发布目标。 +**在所有平台移除 WASM。** 没有已发布原生引擎的目标仍需转换能力。共享 WASM 包为这些目标提供转换,无需引入新的原生发布目标。 **将 Python Office 伴随目录设为可选。** 运行时承载共享的 `dsh` CLI 及其 Web profile,而不只有默认 SDK profile。强制携带目标引擎使已安装 wheel 具备完整的内置 profile 集合,并在启动前报告载荷不完整。SDK 和 headless 用户也承担引擎的下载与安装体积。 ## Consequences -macOS/Windows 分发物省去 WASM 下载和展开资源。原生包缺失成为必须修复的安装缺陷;转换不会下载引擎。Linux 保留 WASM 的资源和字体要求。kit 的引擎选择测试与真实 npm 安装测试覆盖平台选择;Harness 的 sidecar、wheel 和运行时解析测试覆盖打包要求。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 +声明了原生目标的分发物省去 WASM 资源,其余目标保留 WASM 的资源和字体要求。锁定的 kit 声明了 macOS/Windows ARM64 和 x64 原生包,因此当前 Linux 分发物选择 WASM;kit 发布版本可以新增 Linux 原生目标,无需修改 Harness 的选择规则。这不扩展 Harness 支持的发布平台。Harness 的 sidecar、wheel 和运行时解析测试覆盖已声明原生目标与 WASM 选择,包括原生包缺失。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 [公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index c582de9b25..cc888f75d3 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: 7aa419b5ba405d15eeffc61831486c9648bea81c -README.zh.md: a2c5e9877fc7816a401feac50404818f1fdc5fe5 +README.md: 0122763f44c818a928c31f4c1571c42616675081 +README.zh.md: 8a59e1e3f86b87a31bb3c104fde96a3cede17bca diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7aa419b5ba..0122763f44 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -124,7 +124,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries one matching native engine on macOS/Windows or the WASM engine on Linux. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a2c5e9877f..8a59e1e3f8 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -124,7 +124,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)在 macOS/Windows 携带一个匹配的原生引擎,在 Linux 携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index 18048375be..e5c789c165 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -28,6 +28,7 @@ import { } from './macos-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' +import { selectOfficeEngine } from '../../../scripts/libreoffice-engine.ts' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -123,10 +124,12 @@ async function main(): Promise { const targetName = resolveDesktopBuildTarget() const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } const modules = join(BUILD_ROOT, 'node_modules') + const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8')) + const officeEngine = selectOfficeEngine(officeManifest, target) mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), { recursive: true, dereference: true, - filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined, + filter: source => desktopRuntimeFileExclusion(relative(modules, source), target, officeEngine) === undefined, }) writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', @@ -137,7 +140,6 @@ async function main(): Promise { throw new Error(`desktop runtime: missing private Host file ${file}`) } } - const officeEngine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', '@deepseek-ai', `libreoffice-kit-${officeEngine}`, 'prebuilds.json'))) { throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`) } diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts index 9e6ea293ab..1bc16b2a1c 100644 --- a/apps/desktop/scripts/runtime-file-policy.ts +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -6,10 +6,11 @@ * node_modules tree, including nested package containers. * @param path - Path relative to the production node_modules directory. * @param target - Platform and architecture of the Electron Node runtime. + * @param officeEngine - Engine selected from the installed kit manifest. * @returns Omission reason, or undefined when the entry must be copied. */ export function desktopRuntimeFileExclusion( - path: string, target: { platform: NodeJS.Platform; arch: string }, + path: string, target: { platform: NodeJS.Platform; arch: string }, officeEngine: string, ): string | undefined { const parts = path.split(/[\\/]/u) if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { @@ -24,8 +25,7 @@ export function desktopRuntimeFileExclusion( const name = packageParts.slice(0, nameParts).join('/') const entry = packageParts.slice(nameParts).join('/') if (name.startsWith('@deepseek-ai/libreoffice-kit-')) { - const engine = target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}` - if (name !== `@deepseek-ai/libreoffice-kit-${engine}`) return 'LibreOffice other platform' + if (name !== `@deepseek-ai/libreoffice-kit-${officeEngine}`) return 'LibreOffice other platform' } if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { return 'fs-ext compiler output' diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts index 283a4f5842..76f5c8d95e 100644 --- a/apps/desktop/tests/runtime-file-policy.spec.ts +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -59,7 +59,7 @@ it('omits development artifacts while preserving executable modules, assets and } cpSync(modules, join(output, 'node_modules'), { recursive: true, dereference: true, - filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === undefined, + filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows, 'win32-x64') === undefined, }) for (const path of removed) expect(existsSync(join(output, 'node_modules', path)), path).toBe(false) for (const path of retained) expect(readFileSync(join(output, 'node_modules', path), 'utf8'), path).toBe(`payload:${path}`) @@ -72,15 +72,28 @@ it('omits development artifacts while preserving executable modules, assets and }) it('applies package-specific rules inside scoped and nested dependency containers', () => { - expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows)).toBeDefined() - expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows)).toBeDefined() - expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows)).toBeUndefined() - expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows)).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows, 'win32-x64')).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows, 'win32-x64')).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows, 'win32-x64')).toBeUndefined() }) it('retains native prebuilds for the selected macOS architecture', () => { const mac = { platform: 'darwin' as const, arch: 'arm64' } - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac)).toBeUndefined() - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac)).toBeDefined() - expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac)).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac, 'darwin-arm64')).toBeUndefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac, 'darwin-arm64')).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac, 'darwin-arm64')).toBeDefined() +}) + +it.each([ + ['linux', 'x64', 'linux-x64'], + ['linux', 'arm64', 'wasm'], + ['freebsd', 'x64', 'wasm'], +] as const)('retains only the selected Office engine for %s/%s', (platform, arch, engine) => { + for (const candidate of ['linux-x64', 'darwin-arm64', 'wasm']) { + const omitted = desktopRuntimeFileExclusion( + `@deepseek-ai/libreoffice-kit-${candidate}/prebuilds.json`, { platform, arch }, engine, + ) + expect(omitted === undefined).toBe(candidate === engine) + } }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 37cbf2e0c9..58055065f9 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 0a6b0ae80e1cbab9f6434035ac07cd4adb3fe7ec -capability-seams.zh.md: 87613f930550d03ac3c42d57dcb73860d1242916 +capability-seams.md: d4899950a6872e6d5b1ad4496d2ab88acda8d871 +capability-seams.zh.md: 9806901d3837ec41ceca2d7494cb03edde70ec4d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0a6b0ae80e..d4899950a6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -538,7 +538,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | -| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux. | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 87613f9305..9806901d38 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -540,7 +540,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | -| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上转换;macOS/Windows 使用原生引擎,Linux 使用 Node WASM。 | +| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/subsystems/document-convert.i18n.yaml b/docs/subsystems/document-convert.i18n.yaml index 8ec3e22776..47919b364c 100644 --- a/docs/subsystems/document-convert.i18n.yaml +++ b/docs/subsystems/document-convert.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/subsystems/document-convert.md -document-convert.md: 2dea31918e92524606ad85826de276c2af05f06d -document-convert.zh.md: 22f0ec54615ebbf86743de6320ec4e8cc3fc8113 +document-convert.md: 1e17edb6314ef7c99cbf29313f64faa6a4b918cf +document-convert.zh.md: 478043bae519ecab2d3c2ccbc46426f14c9c4f6e diff --git a/docs/subsystems/document-convert.md b/docs/subsystems/document-convert.md index 2dea31918e..1e17edb631 100644 --- a/docs/subsystems/document-convert.md +++ b/docs/subsystems/document-convert.md @@ -29,7 +29,7 @@ The provider admits the deferred read before allocating source bytes, shares con ## Engine selection and limits -The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. macOS and Windows require their matching ARM64 or x64 native engine; Linux uses Node WASM. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. +The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. The [Host provider configuration](../../packages/document/document-convert-libreoffice/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. diff --git a/docs/subsystems/document-convert.zh.md b/docs/subsystems/document-convert.zh.md index 22f0ec5461..478043bae5 100644 --- a/docs/subsystems/document-convert.zh.md +++ b/docs/subsystems/document-convert.zh.md @@ -29,7 +29,7 @@ ## 引擎选择和限制 -外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。macOS 和 Windows 要求匹配的 ARM64 或 x64 原生引擎;Linux 使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 +外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 [Host 提供方配置](../../packages/document/document-convert-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml index 5c7acd04ef..59329ef29d 100644 --- a/packages/document/README.i18n.yaml +++ b/packages/document/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/document/README.md -README.md: 0f9d3f1ae0cb154a445e34debe0bf49ef5400d1e -README.zh.md: 0325634d6c435b6d8aeebf202d04f84fa4975e33 +README.md: 693a831fb7a501f8c0376271715298c06b33efd8 +README.zh.md: ac4719874761c9267c25eee763013b5f7f5d9290 diff --git a/packages/document/README.md b/packages/document/README.md index 0f9d3f1ae0..693a831fb7 100644 --- a/packages/document/README.md +++ b/packages/document/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. macOS and Windows use their matching native engine; Linux uses Node WASM. +Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. Targets with a declared native engine use it; other targets use Node WASM. ## Table of Contents diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md index 0325634d6c..ac47198747 100644 --- a/packages/document/README.zh.md +++ b/packages/document/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 摘要 -在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。macOS 和 Windows 使用匹配的原生引擎;Linux 使用 Node WASM。 +在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。声明了原生引擎的目标使用原生引擎,其余目标使用 Node WASM。 ## 目录 diff --git a/packages/document/document-convert-libreoffice/README.i18n.yaml b/packages/document/document-convert-libreoffice/README.i18n.yaml index 13f28d09b0..4aaae7abaf 100644 --- a/packages/document/document-convert-libreoffice/README.i18n.yaml +++ b/packages/document/document-convert-libreoffice/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/document/document-convert-libreoffice/README.md -README.md: 1c287b714ace289fa03f8be6da722fbe78e64065 -README.zh.md: 796d14c3bd873a3a6a24d4691f0d406d0894a122 +README.md: c083215ea2e3fa067043dac045becd892e0ca406 +README.zh.md: d10c1a14792c80b3608b3302958de4546b817cda diff --git a/packages/document/document-convert-libreoffice/README.md b/packages/document/document-convert-libreoffice/README.md index 1c287b714a..c083215ea2 100644 --- a/packages/document/document-convert-libreoffice/README.md +++ b/packages/document/document-convert-libreoffice/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Convert Office documents to PDFs on the Host computer. macOS and Windows use their matching native LibreOffice engine; Linux uses Node WASM. The provider accepts DOC, DOCX, XLS, XLSX, PPT, and PPTX. OOXML conversion returns missing-font names; binary Office conversion returns an empty list. +Convert Office documents to PDFs on the Host computer. Targets with a declared native LibreOffice engine use it; other targets use Node WASM. The provider accepts DOC, DOCX, XLS, XLSX, PPT, and PPTX. OOXML conversion returns missing-font names; binary Office conversion returns an empty list. ## Table of Contents @@ -27,7 +27,7 @@ Convert Office documents to PDFs on the Host computer. macOS and Windows use the The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-convert`. Independent compositions mount `@deepseek-ai/dsh-document-convert-libreoffice` as a `cordis.yml` row. -The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. npm selects the matching macOS/Windows native package or the Linux-only WASM package. A missing native engine rejects conversion on macOS/Windows without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. +The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. | Field | Default | Meaning | |---|---|---| diff --git a/packages/document/document-convert-libreoffice/README.zh.md b/packages/document/document-convert-libreoffice/README.zh.md index 796d14c3bd..d10c1a1479 100644 --- a/packages/document/document-convert-libreoffice/README.zh.md +++ b/packages/document/document-convert-libreoffice/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在宿主计算机上将 Office 文档转换为 PDF。macOS 和 Windows 使用匹配的原生 LibreOffice 引擎;Linux 使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。 +在宿主计算机上将 Office 文档转换为 PDF。声明了原生 LibreOffice 引擎的目标使用原生引擎,其余目标使用 Node WASM。提供方支持 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。OOXML 转换返回缺失字体名称;二进制 Office 转换返回空列表。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-reference" [Web bundle](../../bundle/web-app/README.zh.md)以 `document-convert` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-convert-libreoffice`。 -此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。npm 选择匹配的 macOS/Windows 原生包或仅限 Linux 的 WASM 包。macOS/Windows 缺少原生引擎时拒绝转换,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 +此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 | 字段 | 默认值 | 含义 | |---|---|---| diff --git a/packages/document/document-convert-libreoffice/package.json b/packages/document/document-convert-libreoffice/package.json index f16000844f..eb020306ef 100644 --- a/packages/document/document-convert-libreoffice/package.json +++ b/packages/document/document-convert-libreoffice/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-document-convert-libreoffice", - "description": "Host Office conversion with native macOS/Windows engines and Linux Node WASM", + "description": "Host Office conversion with a declared native target engine or Node WASM", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 631a6680d5..70ecb5d18f 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: ba1814ce00dea9dd35bcfbb330801942b86af155 -README.zh.md: 792c7e06a29be987a25887882b6df976138b194f +README.md: adb2fb71c3893d9360d7593826fdca609f770b6e +README.zh.md: a4decd5040ada700e84b0f1b3bacba7bbec8a56f diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index ba1814ce00..adb2fb71c3 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -39,4 +39,4 @@ Production deployment permits unused workspace patches for packages outside the From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. -The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: native on macOS/Windows and WASM on Linux. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: the target’s declared native engine, or WASM when no native engine is declared. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 792c7e06a2..a4decd5040 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -39,4 +39,4 @@ Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 -已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:macOS/Windows 使用原生引擎,Linux 使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 +已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:使用目标已声明的原生引擎,未声明原生引擎时使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index d642eec5e2..23551b2705 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -74,7 +74,6 @@ class RuntimeBuildHook(BuildHookInterface): ) expected_executable = matches[0][1] target = next(name for name, value in _PLATFORMS.items() if value[0] == platform_tag) - engine = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted( runtime_dir.glob("deepseek-harness-sdk-runtime-*") if runtime_dir.is_dir() else [] @@ -96,9 +95,15 @@ class RuntimeBuildHook(BuildHookInterface): ) for executable in runtime_files: if executable == office: - for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): - if not (office / "node_modules" / required).is_file(): - raise RuntimeError(f"runtime Office dependency is missing: {office / required}") + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + if not adapter.is_file(): + raise RuntimeError(f"runtime Office dependency is missing: {adapter}") + native = target.replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) + engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" + required = office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json" + if not required.is_file(): + raise RuntimeError(f"runtime Office dependency is missing: {required}") continue if not executable.is_file(): raise RuntimeError(f"runtime executable is not a file: {executable}") diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 546bab36c7..48c2b26057 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -21,6 +21,7 @@ console command requires ``DSH_HOME`` for the same reason. from __future__ import annotations +import json import os import platform import shutil @@ -89,13 +90,20 @@ def bundled_runtime_path() -> Path: + _EXE_ACQUISITION_HINT ) office = path.with_name(f"{path.name.removesuffix('.exe')}-office") - engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") - for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): - if not (office / "node_modules" / required).is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " - + _EXE_ACQUISITION_HINT - ) + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + if not adapter.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " + + _EXE_ACQUISITION_HINT + ) + native = tag.replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) + engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" + if not (office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json").is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the Office sidecar engine {engine} at {office}. " + + _EXE_ACQUISITION_HINT + ) return path diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b3a9e259ef..39022df00c 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -164,6 +164,7 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> @pytest.mark.parametrize("platform_tag,selected", [ ("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"), ("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"), + ("manylinux_2_28_x86_64", "linux-x64"), ("macosx_14_0_arm64", "wasm"), ]) @pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"]) def test_office_wheel_requires_only_target_engine( @@ -177,7 +178,9 @@ def test_office_wheel_requires_only_target_engine( engine = ({"kind": "native", "executable": "bin/helper"} if native else {"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"}) with zipfile.ZipFile(wheel, "w") as archive: - archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", "{}") + archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", json.dumps({ + "optionalDependencies": {f"@deepseek-ai/libreoffice-kit-{selected}": "0.0.1"}, + })) base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}" if invalid != "missing-engine": archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine})) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 0c6fa828a2..a1b071e7a0 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import subprocess import sys @@ -19,14 +20,19 @@ from deepseek_harness_runtime import ( ) -def _office_sidecar(executable: Path) -> Path: +def _office_sidecar(executable: Path, native_targets: tuple[str, ...] = ("darwin-arm64", "darwin-x64", "win32-x64")) -> Path: office = executable.with_name(f"{executable.name.removesuffix('.exe')}-office") tag = executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe") - engine = "wasm" if tag.startswith("linux-") else tag.replace("win-", "win32-").replace("macos-", "darwin-") + native = tag.replace("win-", "win32-").replace("macos-", "darwin-") + engine = native if native in native_targets else "wasm" for required in ("@deepseek-ai/libreoffice-kit/package.json", f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json"): path = office / "node_modules" / required path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{}") + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + adapter.write_text(json.dumps({"optionalDependencies": { + f"@deepseek-ai/libreoffice-kit-{target}": "0.0.1" for target in (*native_targets, "wasm") + }}), encoding="utf-8") return office @@ -229,8 +235,14 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path: assert sentinel.read_text() == "done" -@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "win-x64"]) -def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str) -> None: +@pytest.mark.parametrize("target,native_targets", [ + ("linux-x64", ()), ("linux-arm64", ()), + ("macos-arm64", ("darwin-arm64",)), ("macos-x64", ("darwin-x64",)), ("win-x64", ("win32-x64",)), + ("linux-x64", ("linux-x64",)), ("macos-arm64", ()), +]) +def test_runtime_requires_its_platform_office_engine( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str, native_targets: tuple[str, ...], +) -> None: extension = ".exe" if target.startswith("win-") else "" executable = tmp_path / "runtime" / f"deepseek-harness-sdk-runtime-{target}{extension}" executable.parent.mkdir() @@ -238,13 +250,13 @@ def test_runtime_requires_its_platform_office_engine(tmp_path: Path, monkeypatch executable.with_name(f"{executable.stem}-rg{extension}").touch() if target.startswith("macos-"): Path(f"{executable}-spawn-helper").touch() - office = _office_sidecar(executable) + office = _office_sidecar(executable, native_targets) monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: target) assert runtime.bundled_runtime_path() == executable engine = next(office.glob("node_modules/@deepseek-ai/libreoffice-kit-*/prebuilds.json")) engine.unlink() - foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if target.startswith("linux-") else "libreoffice-kit-wasm") / "prebuilds.json" + foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if engine.parent.name == "libreoffice-kit-wasm" else "libreoffice-kit-wasm") / "prebuilds.json" foreign.parent.mkdir(parents=True, exist_ok=True) foreign.write_text("{}") with pytest.raises(FileNotFoundError, match="Office sidecar"): diff --git a/scripts/build-exe-for-python-sdk-office.spec.ts b/scripts/build-exe-for-python-sdk-office.spec.ts index de7ae69b5b..273bf874e8 100644 --- a/scripts/build-exe-for-python-sdk-office.spec.ts +++ b/scripts/build-exe-for-python-sdk-office.spec.ts @@ -94,33 +94,37 @@ it('rejects an ancestor dependency outside the deployed closure', async () => { .rejects.toThrow('outside the deployed closure') }) -it('rejects an optional WASM engine resolved outside the deployed closure', async () => { +it('requires the declared WASM engine inside the deployed closure', async () => { const { root, staging, destination, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) - const ambient = await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) + await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) - .rejects.toThrow(`Python Office dependency is outside the deployed closure: ${ambient}`) + .rejects.toThrow('Python Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.') await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) }) it.each([ - ['darwin', 'arm64'], ['darwin', 'x64'], ['win32', 'arm64'], ['win32', 'x64'], ['linux', 'x64'], -])('copies only the %s/%s engine even when other engines are staged', async (platform, arch) => { + ['darwin', 'arm64', 'darwin-arm64'], ['darwin', 'x64', 'darwin-x64'], + ['win32', 'arm64', 'win32-arm64'], ['win32', 'x64', 'win32-x64'], + ['linux', 'x64', 'linux-x64'], ['linux', 'arm64', 'wasm'], ['freebsd', 'x64', 'wasm'], +])('copies only the %s/%s engine even when other engines are staged', async (platform, arch, selected) => { const { staging, destination, packageAt } = await fixture() - const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm'] + const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'linux-x64', 'wasm'] const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`) await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) }) for (const name of names) await packageAt(name) - const expected = `@deepseek-ai/libreoffice-kit-${platform === 'linux' ? 'wasm' : `${platform}-${arch}`}` + const expected = `@deepseek-ai/libreoffice-kit-${selected}` const packages = await copyOfficeSidecar(staging, destination, { platform, arch }) expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ 'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`, ]) }) -it.each(['darwin', 'win32'])('%s requires its native package even when WASM is staged', async (platform) => { +it.each(['darwin', 'win32', 'linux'])('%s requires its native package even when WASM is staged', async (platform) => { const { staging, destination, packageAt } = await fixture() - await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) + await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { + '@deepseek-ai/libreoffice-kit-wasm': '0.0.1', [`@deepseek-ai/libreoffice-kit-${platform}-arm64`]: '0.0.1', + } }) await packageAt('@deepseek-ai/libreoffice-kit-wasm') await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' })) .rejects.toThrow(`Python Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`) diff --git a/scripts/build-exe-for-python-sdk-office.ts b/scripts/build-exe-for-python-sdk-office.ts index 50385ed0f5..0233dca72f 100644 --- a/scripts/build-exe-for-python-sdk-office.ts +++ b/scripts/build-exe-for-python-sdk-office.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' import { cp, mkdir, readFile, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { isAbsolute, join, relative, sep } from 'node:path' +import { selectOfficeEngine } from './libreoffice-engine.ts' /** pkg applies these exclusions to dependency `files` as well as root asset globs. */ export const OFFICE_ASSET_IGNORES = [ @@ -22,7 +23,7 @@ interface PackageManifest { /** * Copy the installed Office dependency tree without changing package contents or executable modes. - * Harness sidecars require the target native engine on macOS/Windows and WASM on Linux. + * Harness sidecars require the kit's declared target native engine, or WASM for other targets. * Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail. * @param staging - Symlink-free deployed Node closure. * @param destination - Target-specific Office directory beside the executable; replaced when present. @@ -34,7 +35,9 @@ export async function copyOfficeSidecar( destination: string, target: { platform: string; arch: string }, ): Promise { - const engineName = `@deepseek-ai/libreoffice-kit-${target.platform === 'linux' ? 'wasm' : `${target.platform}-${target.arch}`}` + const entry = join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit') + const manifest = JSON.parse(await readFile(join(entry, 'package.json'), 'utf8')) as PackageManifest + const engineName = `@deepseek-ai/libreoffice-kit-${selectOfficeEngine(manifest, target)}` const packages = new Set() async function visit(packageDirectory: string): Promise { @@ -53,7 +56,7 @@ export async function copyOfficeSidecar( ...Object.keys(manifest.peerDependencies ?? {}), ]) for (const name of dependencies) { - if (name.startsWith('@deepseek-ai/libreoffice-kit-') && name !== engineName) continue + if (name.startsWith('@deepseek-ai/libreoffice-kit-')) continue const optional = manifest.optionalDependencies?.[name] !== undefined || manifest.peerDependenciesMeta?.[name]?.optional === true const dependencyDirectory = (require.resolve.paths(name) ?? []) @@ -71,7 +74,7 @@ export async function copyOfficeSidecar( } } - await visit(join(staging, 'node_modules', '@deepseek-ai', 'libreoffice-kit')) + await visit(entry) const engineDirectory = join(staging, 'node_modules', engineName) if (!existsSync(engineDirectory)) throw new Error(`Python Office engine ${engineName} required for ${target.platform}/${target.arch} is missing.`) await visit(engineDirectory) diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 04500c942b..4e5bfb4e1e 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -237,7 +237,9 @@ def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platfor raise RuntimeError("Office dependency is missing: libreoffice-kit") engines = f"{office_modules}/@deepseek-ai" target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag) - selected = "wasm" if target.startswith("linux-") else target.replace("win-", "win32-").replace("macos-", "darwin-") + native_target = target.replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(archive.read(adapter)).get("optionalDependencies", {}) + selected = native_target if f"@deepseek-ai/libreoffice-kit-{native_target}" in declared else "wasm" for required in (f"libreoffice-kit-{selected}/prebuilds.json",): if f"{engines}/{required}" not in names: raise RuntimeError(f"Office dependency is missing: {required}") diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index fbe5d3ec86..bf78760459 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -153,7 +153,7 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'documentConvert', pkg: 'document-convert', title: 'Office to PDF conversion', mode: 'seam', implementations: ['document-convert-libreoffice'], consumers: [], - note: 'Authorized Office bytes are converted on the Host using native engines on macOS/Windows and Node WASM on Linux.', + note: 'Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared.', }, { key: 'attachments', diff --git a/scripts/libreoffice-engine.spec.ts b/scripts/libreoffice-engine.spec.ts new file mode 100644 index 0000000000..763a313a75 --- /dev/null +++ b/scripts/libreoffice-engine.spec.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import { selectOfficeEngine } from './libreoffice-engine.ts' + +it.each([ + ['darwin', 'arm64', 'darwin-arm64'], ['darwin', 'x64', 'darwin-x64'], + ['win32', 'arm64', 'win32-arm64'], ['win32', 'x64', 'win32-x64'], + ['linux', 'x64', 'wasm'], ['linux', 'arm64', 'wasm'], + ['darwin', 'other', 'wasm'], ['freebsd', 'x64', 'wasm'], +])('selects the declared engine for %s/%s', (platform, arch, expected) => { + const optionalDependencies = Object.fromEntries( + ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'wasm'] + .map(engine => [`@deepseek-ai/libreoffice-kit-${engine}`, '0.0.1']), + ) + expect(selectOfficeEngine({ optionalDependencies }, { platform, arch })).toBe(expected) +}) + +it('selects a declared Linux native target without an OS-specific policy change', () => { + expect(selectOfficeEngine({ optionalDependencies: { '@deepseek-ai/libreoffice-kit-linux-x64': '1' } }, + { platform: 'linux', arch: 'x64' })).toBe('linux-x64') +}) + +it('uses WASM when no native targets are declared', () => { + expect(selectOfficeEngine({}, { platform: 'darwin', arch: 'x64' })).toBe('wasm') +}) diff --git a/scripts/libreoffice-engine.ts b/scripts/libreoffice-engine.ts new file mode 100644 index 0000000000..7b3b270daa --- /dev/null +++ b/scripts/libreoffice-engine.ts @@ -0,0 +1,17 @@ +/** Select the single LibreOffice payload from the kit's declared native targets. */ + +/** + * Require a declared native target even when its optional npm package is absent. + * Targets without a declared native engine use WASM. + * @param manifest - Installed LibreOffice kit package manifest. + * @param target - Node platform and architecture of the distribution. + * @returns Engine suffix used by the kit's npm packages. + */ +export function selectOfficeEngine( + manifest: { optionalDependencies?: Record }, + target: { platform: string; arch: string }, +): string { + const native = `${target.platform}-${target.arch}` + return Object.hasOwn(manifest.optionalDependencies ?? {}, `@deepseek-ai/libreoffice-kit-${native}`) + ? native : 'wasm' +} diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index e523813603..aef9b61154 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -805,7 +805,11 @@ def smoke_sdk_office(executable: Path) -> None: else: shutil.copy2(source, destination) office = root / f"{stem}-office" - expected_backend = "wasm" if sys.platform == "linux" else "native" + adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" + native = stem.removeprefix("deepseek-harness-sdk-runtime-").replace("win-", "win32-").replace("macos-", "darwin-") + declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) + selected = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" + expected_backend = "wasm" if selected == "wasm" else "native" engines = [ json.loads(manifest.read_text())["engine"]["kind"] for manifest in (office / "node_modules/@deepseek-ai").glob("libreoffice-kit-*/prebuilds.json") From 43839d12095497807569d76044481aedd186a1f9 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:07:38 +0800 Subject: [PATCH 4/9] docs(document): refresh shared conversion module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 7 +++++++ docs/module-graph.zh.md | 7 +++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 16a7db1d7a..cbec78658d 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: b2293415199536b32f2f5a6ef2147a10cde4fc47 -module-graph.zh.md: 50717b4b940cb80c2fb366554da85796fd028f3f +module-graph.md: e434b79e67ede56c5b6640c61406cf7c05c0f50a +module-graph.zh.md: 46488b215ac2f2d27750b51b1fa4e98ab6bedeb2 diff --git a/docs/module-graph.md b/docs/module-graph.md index b229341519..e434b79e67 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -224,6 +224,10 @@ flowchart TD pkg_tool_present["tool-present"] pkg_workspace_changes["workspace-changes"] end + subgraph group_document["packages/document"] + pkg_document_convert["document-convert"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] + end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] @@ -408,6 +412,7 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants + pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1374,6 +1379,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | +| [`document-convert`](../packages/document/document-convert) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1402,6 +1408,7 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 50717b4b94..46488b215a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -226,6 +226,10 @@ flowchart TD pkg_tool_present["tool-present"] pkg_workspace_changes["workspace-changes"] end + subgraph group_document["packages/document"] + pkg_document_convert["document-convert"] + pkg_document_convert_libreoffice["document-convert-libreoffice"] + end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] @@ -410,6 +414,7 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants + pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1376,6 +1381,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | +| [`document-convert`](../packages/document/document-convert) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1404,6 +1410,7 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | From 11669006e6af481d764666f98b1275f2c19f59a2 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 16:50:04 +0800 Subject: [PATCH 5/9] refactor(document): consolidate Office conversion into office-to-pdf --- ...9-14-independent-libreoffice-kit.i18n.yaml | 4 +- .../2026-09-14-independent-libreoffice-kit.md | 2 +- ...26-09-14-independent-libreoffice-kit.zh.md | 2 +- ...6-09-15-bounded-office-rendering.i18n.yaml | 4 +- .../2026-09-15-bounded-office-rendering.md | 6 +- .../2026-09-15-bounded-office-rendering.zh.md | 6 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 10 +- docs/capability-seams.zh.md | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 101 +++++++++--------- docs/config-catalog.zh.md | 101 +++++++++--------- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 7 +- docs/module-graph.zh.md | 7 +- docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 2 +- docs/subsystems/README.zh.md | 2 +- ...vert.i18n.yaml => office-to-pdf.i18n.yaml} | 6 +- .../{document-convert.md => office-to-pdf.md} | 25 +++-- ...ment-convert.zh.md => office-to-pdf.zh.md} | 25 +++-- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 +- packages/bundle/web-app/package.json | 3 +- .../web-app/tests/document-conversion.e2e.ts | 20 ++-- packages/document/README.i18n.yaml | 4 +- packages/document/README.md | 7 +- packages/document/README.zh.md | 7 +- .../README.i18n.yaml | 6 -- .../document-convert-libreoffice/package.json | 43 -------- packages/document/document-convert/README.md | 75 ------------- .../document/document-convert/README.zh.md | 75 ------------- .../document/document-convert/src/index.ts | 44 -------- .../document/document-convert/src/types.ts | 71 ------------ .../document/document-convert/tsconfig.json | 18 ---- .../README.i18n.yaml | 6 +- .../README.md | 10 +- .../README.zh.md | 10 +- .../package.json | 18 ++-- packages/document/office-to-pdf/src/errors.ts | 15 +++ .../document/office-to-pdf/src/identity.ts | 31 ++++++ .../src/index.ts | 49 ++++++--- .../src/output.ts | 12 +-- .../src/queue.ts | 49 ++++----- packages/document/office-to-pdf/src/types.ts | 43 ++++++++ .../tests/output.spec.ts | 0 .../tests/provider.spec.ts | 21 ++-- .../tests/queue.spec.ts | 13 ++- .../tsconfig.json | 2 +- .../node/external_packages/libreoffice-kit.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 94 ++++++++-------- pnpm-lock.yaml | 21 +--- scripts/gen-cordis-catalog.ts | 10 +- scripts/gen-doc-graphs.ts | 4 +- .../verify-package-readme-model-experience.ts | 3 +- tsconfig.base.json | 5 +- tsconfig.host.json | 3 +- 62 files changed, 453 insertions(+), 697 deletions(-) rename docs/subsystems/{document-convert.i18n.yaml => office-to-pdf.i18n.yaml} (67%) rename docs/subsystems/{document-convert.md => office-to-pdf.md} (59%) rename docs/subsystems/{document-convert.zh.md => office-to-pdf.zh.md} (58%) delete mode 100644 packages/document/document-convert-libreoffice/README.i18n.yaml delete mode 100644 packages/document/document-convert-libreoffice/package.json delete mode 100644 packages/document/document-convert/README.md delete mode 100644 packages/document/document-convert/README.zh.md delete mode 100644 packages/document/document-convert/src/index.ts delete mode 100644 packages/document/document-convert/src/types.ts delete mode 100644 packages/document/document-convert/tsconfig.json rename packages/document/{document-convert => office-to-pdf}/README.i18n.yaml (69%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/README.md (85%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/README.zh.md (84%) rename packages/document/{document-convert => office-to-pdf}/package.json (62%) create mode 100644 packages/document/office-to-pdf/src/errors.ts create mode 100644 packages/document/office-to-pdf/src/identity.ts rename packages/document/{document-convert-libreoffice => office-to-pdf}/src/index.ts (79%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/src/output.ts (66%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/src/queue.ts (82%) create mode 100644 packages/document/office-to-pdf/src/types.ts rename packages/document/{document-convert-libreoffice => office-to-pdf}/tests/output.spec.ts (100%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/tests/provider.spec.ts (93%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/tests/queue.spec.ts (97%) rename packages/document/{document-convert-libreoffice => office-to-pdf}/tsconfig.json (89%) diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml index 7f6119ae93..c3570fad78 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.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-14-independent-libreoffice-kit.md -2026-09-14-independent-libreoffice-kit.md: f9f27585d157025477e5f5d5f6d74dfca142ce83 -2026-09-14-independent-libreoffice-kit.zh.md: 18bf20e28c8f66ea581ac7823f7b004e89153b90 +2026-09-14-independent-libreoffice-kit.md: 13efc41e56a4810273fbb6d9314eeda6053dbe92 +2026-09-14-independent-libreoffice-kit.zh.md: 720ca2ba3f57546825ae14b5b5f0b086fbb7f287 diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md index f9f27585d1..13efc41e56 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md @@ -10,7 +10,7 @@ LibreOffice compilation, source patches, platform qualification, and large binar ## Decision -The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `DocumentConverter` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. +The `deepseek-harness/libreoffice-kit` repository owns the reusable `@deepseek-ai/libreoffice-kit` Node API, its Worker, font handling, engine selection, build recipes, patches, tests, and releases. The API has no Cordis dependency. Harness owns the adapter from its `OfficeToPdf` service to this API, Session authorization, conversion lifetime, transport, Web UI, and application packaging. Kit releases run independently of Harness releases. The kit repository qualifies and publishes the Node API and engine npm packages at a shared version, starting at `0.0.1`. Harness consumes an exact npm version and commits its dependency resolution in `pnpm-lock.yaml`; Harness releases neither build nor publish kit packages. diff --git a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md index 18bf20e28c..720ca2ba3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md @@ -10,7 +10,7 @@ LibreOffice 编译、源码补丁、平台资格验证和大型二进制发布 ## Decision -`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `DocumentConverter` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 +`deepseek-harness/libreoffice-kit` 仓库维护可复用的 `@deepseek-ai/libreoffice-kit` Node API、Worker、字体处理、引擎选择、构建配方、补丁、测试和发布。API 不依赖 Cordis。Harness 负责将自己的 `OfficeToPdf` 服务适配到此 API,以及 Session 授权、转换生命周期、传输、Web UI 和应用打包。 kit 发布流程独立于 Harness 发布流程。kit 仓库验证并以统一版本发布 Node API 和引擎 npm 包,起始版本为 `0.0.1`。Harness 消费精确的 npm 版本,并在 `pnpm-lock.yaml` 中提交依赖解析结果;Harness 发布既不构建也不发布 kit 包。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml index 9cb08e42e0..f4d8c83c91 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.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-15-bounded-office-rendering.md -2026-09-15-bounded-office-rendering.md: c8ffd5f476e9c5a7c7a0f85706872b2da1690681 -2026-09-15-bounded-office-rendering.zh.md: a03a605dd66142d6457d35154a0e1628dcfa7632 +2026-09-15-bounded-office-rendering.md: 242434361e665359ebfa6d934b5257fc0df12ed9 +2026-09-15-bounded-office-rendering.zh.md: b8268a5e9c421bb7b205136f66213acaa7aa8548 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md index c8ffd5f476..242434361e 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -10,9 +10,9 @@ Office preview and explicit document inspection can request the same conversion. ## Decision -The `document-convert` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. +The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. -The [Host provider](../../../../packages/document/document-convert-libreoffice/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. +The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. @@ -28,6 +28,8 @@ Foreground preview and explicit QA requests precede background work. Disabling b **Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. +**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. + ## Consequences The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md index a03a605dd6..b8268a5e9c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -10,9 +10,9 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 ## 决策 -`document-convert` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 +`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 -[宿主提供方](../../../../packages/document/document-convert-libreoffice/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 +[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 @@ -28,6 +28,8 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 **另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 +**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 + ## 后果 缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index cc888f75d3..7c2a3589e2 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: 0122763f44c818a928c31f4c1571c42616675081 -README.zh.md: 8a59e1e3f86b87a31bb3c104fde96a3cede17bca +README.md: 7763152e43cd44a588883b3e346bcaf69e453a40 +README.zh.md: a0758e0c5cc34c5cfd9883d54b490a9eab6f899f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0122763f44..7763152e43 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -124,7 +124,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/document-convert-libreoffice/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Engine assets, licenses, and notices remain in the runtime dependency tree. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 8a59e1e3f8..a0758e0c5c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -124,7 +124,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/document-convert-libreoffice/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。引擎资源、许可证和 notices 保留在运行时依赖树中。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 58055065f9..586e66e3fc 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: d4899950a6872e6d5b1ad4496d2ab88acda8d871 -capability-seams.zh.md: 9806901d3837ec41ceca2d7494cb03edde70ec4d +capability-seams.md: b2799a1f0965d687a95af23b67873de1b86a8560 +capability-seams.zh.md: 23fbcdb5621f3129ac699284bd17359941646041 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d4899950a6..b2799a1f09 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -26,9 +26,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_convert["document-convert"] - svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] + svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -286,8 +285,6 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_convert --> svc_documentConvert - pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -320,6 +317,7 @@ flowchart LR pkg_mcp_client --> svc_mcpResources pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback + pkg_office_to_pdf --> svc_officeToPdf pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode pkg_plugin_manager --> svc_pluginManager @@ -538,7 +536,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. | -| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. | +| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared. | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 9806901d38..23fbcdb562 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -28,9 +28,8 @@ flowchart LR svc_computerUse["ctx.computerUse
Computer-use provider registration"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] - pkg_document_convert["document-convert"] - svc_documentConvert["ctx.documentConvert
Office to PDF conversion"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] + svc_officeToPdf["ctx.officeToPdf
Office to PDF conversion"] pkg_attachment["attachment"] svc_attachments["ctx.attachments
Durable binary attachment storage"] pkg_attachment_local["attachment-local"] @@ -288,8 +287,6 @@ flowchart LR pkg_credentials --> svc_credentials pkg_credentials_local --> svc_credentials pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_document_convert --> svc_documentConvert - pkg_document_convert_libreoffice --> svc_documentConvert pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -322,6 +319,7 @@ flowchart LR pkg_mcp_client --> svc_mcpResources pkg_mcp_resources --> svc_mcpResources pkg_message_feedback --> svc_messageFeedback + pkg_office_to_pdf --> svc_officeToPdf pkg_permission_presets --> svc_permissionPresets pkg_plan_mode --> svc_planMode pkg_plugin_manager --> svc_pluginManager @@ -540,7 +538,7 @@ flowchart LR | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 | | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 | -| `ctx.documentConvert` | `seam` | [`document-convert`](../packages/document/document-convert) | [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 | +| `ctx.officeToPdf` | `core` | [`office-to-pdf`](../packages/document/office-to-pdf) | - | - | - | 已授权的 Office 字节在宿主上使用已声明的原生目标引擎转换;未声明原生目标时使用 Node WASM。 | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0e0def93c2..1749c9d29b 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: 19347950d5ae1bca252342ca84879c700a71a4ad -config-catalog.zh.md: 17cd1130a1d7c17bb1714a4084b41366041f66e4 +config-catalog.md: dd30690fbf89051c7242f0dc8104e15f79e8c163 +config-catalog.zh.md: 42054765eb75afdf3a6cb0598d7b81b612243fc7 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 19347950d5..dd30690fbf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,56 +548,6 @@ export interface Config { Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - - -## `@deepseek-ai/dsh-document-convert-libreoffice` - -```ts config-catalog -/** Provider concurrency and kit rendering/font configuration. */ -export interface Config { - /** Maximum simultaneous conversions; queued callers remain cancellable. */ - maxConcurrentConversions: number - /** Maximum metadata-only jobs awaiting source admission. */ - maxQueuedJobs: number - /** Maximum outstanding conversion readers. */ - maxReaders: number - /** Maximum reserved bytes across admitted source reads and conversions. */ - maxSourceBytes: number - /** Maximum concurrent background jobs; zero refuses speculative work. */ - maxBackgroundConversions: number - /** Maximum retained content-addressed PDFs. */ - maxCachedEntries: number - /** Maximum retained PDF bytes. */ - maxCachedBytes: number - /** Maximum retained source-version aliases to cached content. */ - maxSourceEntries: number - /** Conversion deadline in milliseconds; excludes the DSH queue. */ - timeoutMs: number - /** Maximum authorized source bytes. */ - maxInputBytes: number - /** Maximum complete PDF bytes. */ - maxOutputBytes: number - /** Exported raster-image DPI. */ - maxImageResolution: number - /** Maximum OOXML ZIP entries. */ - maxArchiveEntries: number - /** Maximum total declared uncompressed OOXML bytes. */ - maxUncompressedBytes: number - /** Absolute font roots; omission uses the kit's platform defaults. */ - fontDirectories?: string[] - /** Ordered font-family preference groups; omission retains the kit defaults. */ - fontFallbacks?: string[][] - /** Maximum physical font files indexed by each converter. */ - maxFontFiles: number - /** Maximum individual font-file bytes. */ - maxFontFileBytes: number - /** Maximum original font bytes loaded for a conversion. */ - maxLoadedFontBytes: number -} -``` - -Source: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) - ## `@deepseek-ai/dsh-experimental-agent-team` @@ -1768,6 +1718,56 @@ export interface Config { Source: [`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) + + +## `@deepseek-ai/dsh-office-to-pdf` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +Source: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts) + ## `@deepseek-ai/dsh-permission-presets` @@ -3848,7 +3848,6 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-convert` — abstract `DocumentConverter` ([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 17cd1130a1..42054765eb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -550,56 +550,6 @@ export interface Config { 来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts) - - -## `@deepseek-ai/dsh-document-convert-libreoffice` - -```ts config-catalog -/** Provider concurrency and kit rendering/font configuration. */ -export interface Config { - /** Maximum simultaneous conversions; queued callers remain cancellable. */ - maxConcurrentConversions: number - /** Maximum metadata-only jobs awaiting source admission. */ - maxQueuedJobs: number - /** Maximum outstanding conversion readers. */ - maxReaders: number - /** Maximum reserved bytes across admitted source reads and conversions. */ - maxSourceBytes: number - /** Maximum concurrent background jobs; zero refuses speculative work. */ - maxBackgroundConversions: number - /** Maximum retained content-addressed PDFs. */ - maxCachedEntries: number - /** Maximum retained PDF bytes. */ - maxCachedBytes: number - /** Maximum retained source-version aliases to cached content. */ - maxSourceEntries: number - /** Conversion deadline in milliseconds; excludes the DSH queue. */ - timeoutMs: number - /** Maximum authorized source bytes. */ - maxInputBytes: number - /** Maximum complete PDF bytes. */ - maxOutputBytes: number - /** Exported raster-image DPI. */ - maxImageResolution: number - /** Maximum OOXML ZIP entries. */ - maxArchiveEntries: number - /** Maximum total declared uncompressed OOXML bytes. */ - maxUncompressedBytes: number - /** Absolute font roots; omission uses the kit's platform defaults. */ - fontDirectories?: string[] - /** Ordered font-family preference groups; omission retains the kit defaults. */ - fontFallbacks?: string[][] - /** Maximum physical font files indexed by each converter. */ - maxFontFiles: number - /** Maximum individual font-file bytes. */ - maxFontFileBytes: number - /** Maximum original font bytes loaded for a conversion. */ - maxLoadedFontBytes: number -} -``` - -源码: [`packages/document/document-convert-libreoffice/src/index.ts:14`](../packages/document/document-convert-libreoffice/src/index.ts) - ## `@deepseek-ai/dsh-experimental-agent-team` @@ -1770,6 +1720,56 @@ export interface Config { 来源:[`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) + + +## `@deepseek-ai/dsh-office-to-pdf` + +```ts config-catalog +/** Provider concurrency and kit rendering/font configuration. */ +export interface Config { + /** Maximum simultaneous conversions; queued callers remain cancellable. */ + maxConcurrentConversions: number + /** Maximum metadata-only jobs awaiting source admission. */ + maxQueuedJobs: number + /** Maximum outstanding conversion readers. */ + maxReaders: number + /** Maximum reserved bytes across admitted source reads and conversions. */ + maxSourceBytes: number + /** Maximum concurrent background jobs; zero refuses speculative work. */ + maxBackgroundConversions: number + /** Maximum retained content-addressed PDFs. */ + maxCachedEntries: number + /** Maximum retained PDF bytes. */ + maxCachedBytes: number + /** Maximum retained source-version aliases to cached content. */ + maxSourceEntries: number + /** Conversion deadline in milliseconds; excludes the DSH queue. */ + timeoutMs: number + /** Maximum authorized source bytes. */ + maxInputBytes: number + /** Maximum complete PDF bytes. */ + maxOutputBytes: number + /** Exported raster-image DPI. */ + maxImageResolution: number + /** Maximum OOXML ZIP entries. */ + maxArchiveEntries: number + /** Maximum total declared uncompressed OOXML bytes. */ + maxUncompressedBytes: number + /** Absolute font roots; omission uses the kit's platform defaults. */ + fontDirectories?: string[] + /** Ordered font-family preference groups; omission retains the kit defaults. */ + fontFallbacks?: string[][] + /** Maximum physical font files indexed by each converter. */ + maxFontFiles: number + /** Maximum individual font-file bytes. */ + maxFontFileBytes: number + /** Maximum original font bytes loaded for a conversion. */ + maxLoadedFontBytes: number +} +``` + +来源: [`packages/document/office-to-pdf/src/index.ts:27`](../packages/document/office-to-pdf/src/index.ts) + ## `@deepseek-ai/dsh-permission-presets` @@ -3850,7 +3850,6 @@ export interface Config { - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) -- `@deepseek-ai/dsh-document-convert` — 抽象 `DocumentConverter`([`packages/document/document-convert/src/index.ts`](../packages/document/document-convert/src/index.ts)) - `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index cbec78658d..a6573bee99 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: e434b79e67ede56c5b6640c61406cf7c05c0f50a -module-graph.zh.md: 46488b215ac2f2d27750b51b1fa4e98ab6bedeb2 +module-graph.md: b89bb220fa37e70b83fe879adee71f8d042b0c9a +module-graph.zh.md: 7eff2d9faeea8e49d675ad1894b481cd0e1d0ade diff --git a/docs/module-graph.md b/docs/module-graph.md index e434b79e67..b89bb220fa 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -225,8 +225,7 @@ flowchart TD pkg_workspace_changes["workspace-changes"] end subgraph group_document["packages/document"] - pkg_document_convert["document-convert"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] @@ -412,7 +411,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants - pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1379,7 +1377,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | -| [`document-convert`](../packages/document/document-convert) | `document` | — | +| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1408,7 +1406,6 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 46488b215a..7eff2d9fae 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -227,8 +227,7 @@ flowchart TD pkg_workspace_changes["workspace-changes"] end subgraph group_document["packages/document"] - pkg_document_convert["document-convert"] - pkg_document_convert_libreoffice["document-convert-libreoffice"] + pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] pkg_experimental_agent_team["experimental-agent-team"] @@ -414,7 +413,6 @@ flowchart TD pkg_browser_use --> pkg_brand pkg_computer_use --> pkg_brand pkg_credentials --> pkg_invariants - pkg_document_convert_libreoffice --> pkg_document_convert pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_webworker_runtime --> pkg_client_connection @@ -1381,7 +1379,7 @@ flowchart TD | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | — | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | -| [`document-convert`](../packages/document/document-convert) | `document` | — | +| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1410,7 +1408,6 @@ flowchart TD | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`document-convert-libreoffice`](../packages/document/document-convert-libreoffice) | `document` | [`document-convert`](../packages/document/document-convert) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index c3bebe788a..f8a704b517 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: 0f67589082e06a3a882a531138f05efbf54df702 -README.zh.md: 11522b1049b8dacdbe72ec131a80372e2a075e5f +README.md: d54bd4f45c23100cd1d53080f1ca807a9c177d3d +README.zh.md: 81ef25b69547672e016a2b72e2293daa8bb42eb1 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 0f67589082..d54bd4f45c 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -30,7 +30,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [mcp.md](mcp.md) | external MCP connections, scoped tools and resources, server instructions, protocol results, and configuration ownership | | [user-questions.md](user-questions.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit events, and answerer contracts | -| [document-convert.md](document-convert.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | +| [office-to-pdf.md](office-to-pdf.md) | authorized Office-to-PDF conversion, native/WASM engines, and bounded shared reuse | | [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [shell.md](shell.md) | the shell executor seam: `ShellExecRequest`/`Spec`, `ShellRunResult`, background `ShellProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 11522b1049..81ef25b695 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -30,7 +30,7 @@ | [mcp.md](mcp.zh.md) | 外部 MCP 连接、作用域工具与资源、服务器指令、协议结果与配置归属 | | [user-questions.md](user-questions.zh.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.zh.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计事件和 answerer 约定 | -| [document-convert.md](document-convert.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | +| [office-to-pdf.md](office-to-pdf.zh.md) | 已授权的 Office 到 PDF 转换、原生/WASM 引擎与有界共享复用 | | [attachment.md](attachment.zh.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [shell.md](shell.zh.md) | shell 执行器 seam:`ShellExecRequest`/`Spec`、`ShellRunResult`、后台 `ShellProcess` 句柄 | | [subprocess.md](subprocess.zh.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | diff --git a/docs/subsystems/document-convert.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml similarity index 67% rename from docs/subsystems/document-convert.i18n.yaml rename to docs/subsystems/office-to-pdf.i18n.yaml index 47919b364c..edca358c9c 100644 --- a/docs/subsystems/document-convert.i18n.yaml +++ b/docs/subsystems/office-to-pdf.i18n.yaml @@ -1,6 +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 docs/subsystems/document-convert.md -document-convert.md: 1e17edb6314ef7c99cbf29313f64faa6a4b918cf -document-convert.zh.md: 478043bae519ecab2d3c2ccbc46426f14c9c4f6e +# pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md +office-to-pdf.md: 71254a3a8a8b4665d8d42b935cc836cb7c644b10 +office-to-pdf.zh.md: c11889fc9bd4693d90ec60696fce4b57693fd027 diff --git a/docs/subsystems/document-convert.md b/docs/subsystems/office-to-pdf.md similarity index 59% rename from docs/subsystems/document-convert.md rename to docs/subsystems/office-to-pdf.md index 1e17edb631..71254a3a8a 100644 --- a/docs/subsystems/document-convert.md +++ b/docs/subsystems/office-to-pdf.md @@ -1,6 +1,6 @@ -# Document conversion +# Office to PDF -English | [中文](document-convert.zh.md) +English | [中文](office-to-pdf.zh.md) The [document package family](../../packages/document/README.md) converts Office files to PDFs on the Node Host. Consumers authorize source reads and own presentation; the shared provider owns conversion, bounded admission, and transient PDF reuse. This subsystem creates no model-facing tool or Session event. @@ -8,15 +8,14 @@ The [document package family](../../packages/document/README.md) converts Office | Owner | Responsibility | |---|---| -| [document-convert](../../packages/document/document-convert/README.md) | `ctx.documentConvert`: authorized Office bytes and complete PDF results | -| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.md) | Host concurrency, reusable kit converters, private scratch files, and bounded PDF reads | +| [office-to-pdf](../../packages/document/office-to-pdf/README.md) | `ctx.officeToPdf`: shared LibreOffice conversion, bounded admission, and PDF caching | | [Web bundle](../../packages/bundle/web-app/README.md) | One configurable conversion provider shared by Host consumers | ## Requests and results -[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `DocumentExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `DocumentConverter.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `DocumentConvertError`. +[`OfficeToPdfRequest`](../../packages/document/office-to-pdf/src/types.ts) contains an already-authorized source key/version, optional stat size, a deferred `read(signal, maxBytes)` callback, foreground/background priority, and a `OfficeExtension`: `doc`, `docx`, `xls`, `xlsx`, `ppt`, or `pptx`. `OfficeToPdf.convert(request, signal?)` returns one complete PDF result. Cancellation follows the caller and provider lifetimes; validation, output, and engine failures reject with a classified `OfficeToPdfError`. -`DocumentConvertPriority` is `foreground` for requested preview/QA and `background` for speculation. `DocumentSourceKey` brands the caller-owned authorized source locator. `DocumentConverterGeneration` brands a provider lifetime, and `DocumentConvertKey` brands its content identity; neither opaque value is parsed by consumers. +`OfficeToPdfPriority` is `foreground` for requested preview/QA and `background` for speculation. `OfficeSourceKey` brands the caller-owned authorized source locator. `OfficeToPdfGeneration` brands a provider lifetime, and `OfficeToPdfKey` brands its content identity; neither opaque value is parsed by consumers. | Result field | Meaning | |---|---| @@ -31,7 +30,7 @@ The provider admits the deferred read before allocating source bytes, shares con The external [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API selects its precompiled engines. The kit has an independent version and release workflow, defined by the [release ownership decision](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md). Application builds install the published npm packages. Application packaging requires the target’s declared native engine, or Node WASM when the kit declares no native engine for that target. The [platform engine decision](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging. Invalid metadata, missing required assets, and conversion errors reject without switching engines. Conversion uses disk input and output paths on the Host, with no browser conversion engine or font RPC. -The [Host provider configuration](../../packages/document/document-convert-libreoffice/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. +The [Host provider configuration](../../packages/document/office-to-pdf/README.md#use-this-package) owns concurrency, deadlines, input/output limits, archive limits, image resolution, and font access. Native/WASM implementation and asset distribution belong to the kit workspace. System LibreOffice discovery, runtime engine downloads, persistent PDF caching, and model-facing rendering are outside this provider. @@ -41,11 +40,11 @@ The [Host provider configuration](../../packages/document/document-convert-libre Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentConvert` — `DocumentConverter` (abstract seam) +### `ctx.officeToPdf` — `OfficeToPdf` -Load one provider subclass per context; consumers own source authorization. +A provider lifetime owns all converters, queued calls, and temporary files. ```ts cordis-catalog /** @@ -53,10 +52,10 @@ Load one provider subclass per context; consumers own source authorization. * @param request - authorized metadata and deferred bounded source read. * @param signal - caller cancellation; provider disposal also stops active work. * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise +convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) +Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts) diff --git a/docs/subsystems/document-convert.zh.md b/docs/subsystems/office-to-pdf.zh.md similarity index 58% rename from docs/subsystems/document-convert.zh.md rename to docs/subsystems/office-to-pdf.zh.md index 478043bae5..c11889fc9b 100644 --- a/docs/subsystems/document-convert.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -1,6 +1,6 @@ -# 文档转换 +# Office 转 PDF -[English](document-convert.md) | 中文 +[English](office-to-pdf.md) | 中文 [document 包族](../../packages/document/README.zh.md) 在 Node 宿主上将 Office 文件转换为 PDF。消费者负责源文件读取授权与展示;共享提供方负责转换、有界准入和临时 PDF 复用。此子系统不创建面向模型的工具或 Session 事件。 @@ -8,15 +8,14 @@ | 所有者 | 职责 | |---|---| -| [document-convert](../../packages/document/document-convert/README.zh.md) | `ctx.documentConvert`:已授权 Office 字节和完整 PDF 结果 | -| [document-convert-libreoffice](../../packages/document/document-convert-libreoffice/README.zh.md) | Host 并发、可复用 kit 转换器、私有临时文件和有上限的 PDF 读取 | +| [office-to-pdf](../../packages/document/office-to-pdf/README.zh.md) | `ctx.officeToPdf`:共享 LibreOffice 转换、有界准入和 PDF 缓存 | | [Web bundle](../../packages/bundle/web-app/README.zh.md) | 由宿主消费者共享的单个可配置转换提供方 | ## 请求和结果 -[`DocumentConvertRequest`](../../packages/document/document-convert/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `DocumentExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`DocumentConverter.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `DocumentConvertError` 拒绝。 +[`OfficeToPdfRequest`](../../packages/document/office-to-pdf/src/types.ts)包含已授权的源键与版本、可选 stat 大小、延迟的 `read(signal, maxBytes)` 回调、前台或后台优先级以及 `OfficeExtension`:`doc`、`docx`、`xls`、`xlsx`、`ppt` 或 `pptx`。`OfficeToPdf.convert(request, signal?)` 返回一个完整 PDF 结果。取消遵循调用方和提供方生命周期;校验、输出和引擎失败以分类的 `OfficeToPdfError` 拒绝。 -`DocumentConvertPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`DocumentSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`DocumentConverterGeneration` 表示提供方生命周期,`DocumentConvertKey` 表示其内容身份;消费者不解析这两种不透明值。 +`OfficeToPdfPriority` 对请求的预览或 QA 使用 `foreground`,对推测工作使用 `background`。`OfficeSourceKey` 为调用方拥有的已授权源定位符增加品牌类型。`OfficeToPdfGeneration` 表示提供方生命周期,`OfficeToPdfKey` 表示其内容身份;消费者不解析这两种不透明值。 | 结果字段 | 含义 | |---|---| @@ -31,7 +30,7 @@ 外部 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) Node API 选择其预编译引擎。kit 独立维护版本和发布流程,具体归属由[发布归属决策](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义。应用构建时安装已发布的 npm 包。应用打包要求目标已声明的原生引擎;kit 未为该目标声明原生引擎时使用 Node WASM。[平台引擎决策](../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装和打包规则。元数据无效、必需资源缺失和转换错误都会拒绝请求,不切换引擎。转换在 Host 使用磁盘输入输出路径,不使用浏览器转换引擎或字体 RPC。 -[Host 提供方配置](../../packages/document/document-convert-libreoffice/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 +[Host 提供方配置](../../packages/document/office-to-pdf/README.zh.md#use-this-package)负责并发、期限、输入输出上限、归档上限、图像分辨率和字体访问。原生/WASM 实现和资产分发属于 kit 工作区。系统 LibreOffice 探测、运行时引擎下载、持久 PDF 缓存和面向模型的渲染不属于此提供方。 @@ -41,11 +40,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.documentConvert` — `DocumentConverter` (abstract seam) +### `ctx.officeToPdf` — `OfficeToPdf` -Load one provider subclass per context; consumers own source authorization. +A provider lifetime owns all converters, queued calls, and temporary files. ```ts cordis-catalog /** @@ -53,10 +52,10 @@ Load one provider subclass per context; consumers own source authorization. * @param request - authorized metadata and deferred bounded source read. * @param signal - caller cancellation; provider disposal also stops active work. * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. */ -abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise +convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise ``` -Source: [`packages/document/document-convert/src/index.ts`](../../packages/document/document-convert/src/index.ts) +Source: [`packages/document/office-to-pdf/src/index.ts`](../../packages/document/office-to-pdf/src/index.ts) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index c2f7020c88..13322367f4 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-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/web-app/README.md -README.md: 2b5c4f48f95fc40b7e25c33e4b14a510c0ace34f -README.zh.md: a8117f8f7d1c3f12016d37c37b81e5a8734531aa +README.md: f733218e857eaf42a8fb2779646b941c5cc1bd4f +README.zh.md: 5c058067c6f849486229e9e400109565eccc3aea diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 2b5c4f48f9..f733218e85 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ Each browser session composes its own agent from the shipped presets (the `stand
Implementation internals — click to expand -The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `document-convert` row mounts one lazy [Office conversion provider](../../document/document-convert-libreoffice/README.md) for Host consumers, including Desktop compositions using this bundle. +The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. ### Patch semantics diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index a8117f8f7d..5c058067c6 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开 -此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`document-convert` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/document-convert-libreoffice/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 +此 bundle 由一个补丁和一个运行时胶水插件组成。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 ### patch 语义 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 4f20b57769..5805c00a35 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -225,8 +225,8 @@ name: '@deepseek-ai/dsh-client-ui-sidebar-right' - - id: document-convert - name: '@deepseek-ai/dsh-document-convert-libreoffice' + - id: office-to-pdf + name: '@deepseek-ai/dsh-office-to-pdf' # The right Sidebar's document tab: bounded file reads with selectable # Markdown, code, HTML, PDF, and plain-text renderers. diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 04a344f8e4..4406a8b7d9 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -123,8 +123,7 @@ "open": "^11.0.0", "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-document-convert": "workspace:^", - "@deepseek-ai/dsh-document-convert-libreoffice": "workspace:^" + "@deepseek-ai/dsh-office-to-pdf": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/bundle/web-app/tests/document-conversion.e2e.ts b/packages/bundle/web-app/tests/document-conversion.e2e.ts index 2f66681703..5e80c99113 100644 --- a/packages/bundle/web-app/tests/document-conversion.e2e.ts +++ b/packages/bundle/web-app/tests/document-conversion.e2e.ts @@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' -import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' -import * as LibreOfficeProvider from '@deepseek-ai/dsh-document-convert-libreoffice' +import { OfficeSourceKey, type OfficeToPdfRequest } from '@deepseek-ai/dsh-office-to-pdf' +import * as LibreOfficeProvider from '@deepseek-ai/dsh-office-to-pdf' import { expect, it, onTestFinished } from 'vitest' it('loads one shared conversion row and retains caller-owned PDFs after disposal', async () => { @@ -20,9 +20,9 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }) const rows = loadOverlayPatches('conversion-test', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) .flatMap(patch => patch.insert ?? []) - .filter(row => row.name === '@deepseek-ai/dsh-document-convert-libreoffice') - expect(rows.map(row => row.id)).toEqual(['document-convert']) - const configured = applyEntryPatches(rows, [{ id: 'document-convert', config: { maxConcurrentConversions: 1 } }], + .filter(row => row.name === '@deepseek-ai/dsh-office-to-pdf') + expect(rows.map(row => row.id)).toEqual(['office-to-pdf']) + const configured = applyEntryPatches(rows, [{ id: 'office-to-pdf', config: { maxConcurrentConversions: 1 } }], (message) => { throw new Error(message) }) const configPath = join(directory, 'cordis.yml') await writeFile(configPath, JSON.stringify(configured)) @@ -33,21 +33,21 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal ctx.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-document-convert-libreoffice') throw new Error(`Unexpected plugin: ${specifier}`) + if (specifier !== '@deepseek-ai/dsh-office-to-pdf') throw new Error(`Unexpected plugin: ${specifier}`) return LibreOfficeProvider }, } as unknown as NonNullable await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() - const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'document-convert')! + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'office-to-pdf')! await entry.fiber!.await() expect(ctx.get('documentRenderController')).toBeUndefined() expect(ctx.get('skills')).toBeUndefined() const bytes = await readFile(new URL('./fixtures/document-conversion.docx', import.meta.url)) let reads = 0 - const request = (key: string): DocumentConvertRequest => ({ + const request = (key: string): OfficeToPdfRequest => ({ extension: 'docx', priority: 'foreground', - source: { key: DocumentSourceKey(key), version: 'fixture', bytes: bytes.length, + source: { key: OfficeSourceKey(key), version: 'fixture', bytes: bytes.length, async read(signal, maxBytes) { signal.throwIfAborted() expect(bytes.length).toBeLessThanOrEqual(maxBytes) @@ -56,7 +56,7 @@ it('loads one shared conversion row and retains caller-owned PDFs after disposal }, }, }) - const provider = ctx.documentConvert + const provider = ctx.officeToPdf const [first, second] = await Promise.all([provider.convert(request('first')), provider.convert(request('second'))]) expect(reads).toBe(2) expect(Buffer.from(first.pdf).subarray(0, 5).toString()).toBe('%PDF-') diff --git a/packages/document/README.i18n.yaml b/packages/document/README.i18n.yaml index 59329ef29d..4eb834b43a 100644 --- a/packages/document/README.i18n.yaml +++ b/packages/document/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/document/README.md -README.md: 693a831fb7a501f8c0376271715298c06b33efd8 -README.zh.md: ac4719874761c9267c25eee763013b5f7f5d9290 +README.md: 8993c608169ef08abe45babcf33ce6552c147425 +README.zh.md: c5775ae4deb9f4dea36df104f93c8e016e44abbd diff --git a/packages/document/README.md b/packages/document/README.md index 693a831fb7..8993c60816 100644 --- a/packages/document/README.md +++ b/packages/document/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Convert authorized Office files to reusable PDFs on the Host. The conversion family provides a shared conversion operation and a LibreOffice kit provider. Targets with a declared native engine use it; other targets use Node WASM. +Convert authorized Office files to reusable PDFs on the Host. The shared service converts through LibreOffice kit. Targets with a declared native engine use it; other targets use Node WASM. ## Table of Contents @@ -26,8 +26,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference | Package | Role | ctx key | |---|---|---| -| [document-convert](document-convert/README.md) | Authorized Office bytes to complete PDF results | `ctx.documentConvert` | -| [document-convert-libreoffice](document-convert-libreoffice/README.md) | Host kit provider with cancellable concurrency and private scratch files | `ctx.documentConvert` | +| [office-to-pdf](office-to-pdf/README.md) | Authorized Office bytes to complete PDFs with bounded queues and caching | `ctx.officeToPdf` | ----- @@ -36,7 +35,7 @@ Each package owns its configuration and lifetime rules; the subsystem reference Consumers own source authorization and presentation. -- [Document conversion](../../docs/subsystems/document-convert.md) — shared operation and generated service reference. +- [Document conversion](../../docs/subsystems/office-to-pdf.md) — shared operation and generated service reference. - [Independent kit ownership](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) — engine distribution and application integration. - [Workspace Files](../api/workspace-files/README.md) — authorized bounded source reads. diff --git a/packages/document/README.zh.md b/packages/document/README.zh.md index ac47198747..c5775ae4de 100644 --- a/packages/document/README.zh.md +++ b/packages/document/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 摘要 -在宿主上将已授权的 Office 文件转换为可复用的 PDF。转换包族提供共享转换操作和 LibreOffice kit 提供方。声明了原生引擎的目标使用原生引擎,其余目标使用 Node WASM。 +在宿主上将已授权的 Office 文件转换为可复用的 PDF。共享服务使用 LibreOffice kit 执行转换。声明了原生引擎的目标使用原生引擎,其余目标使用 Node WASM。 ## 目录 @@ -26,8 +26,7 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| -| [document-convert](document-convert/README.zh.md) | 将已授权 Office 字节转换为完整 PDF 结果 | `ctx.documentConvert` | -| [document-convert-libreoffice](document-convert-libreoffice/README.zh.md) | 使用可取消并发和私有临时文件的 Host kit 提供方 | `ctx.documentConvert` | +| [office-to-pdf](office-to-pdf/README.zh.md) | 将已授权 Office 字节转换为完整 PDF,并提供有界队列和缓存 | `ctx.officeToPdf` | ----- @@ -36,7 +35,7 @@ kind: "package-group" 消费者负责源文件授权与展示。 -- [文档转换](../../docs/subsystems/document-convert.zh.md) — 共享操作和生成的服务参考。 +- [文档转换](../../docs/subsystems/office-to-pdf.zh.md) — 共享操作和生成的服务参考。 - [独立 kit 所有权](../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md) — 引擎分发与应用集成。 - [工作区文件](../api/workspace-files/README.zh.md) — 已授权的有界源文件读取。 diff --git a/packages/document/document-convert-libreoffice/README.i18n.yaml b/packages/document/document-convert-libreoffice/README.i18n.yaml deleted file mode 100644 index 4aaae7abaf..0000000000 --- a/packages/document/document-convert-libreoffice/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/document/document-convert-libreoffice/README.md -README.md: c083215ea2e3fa067043dac045becd892e0ca406 -README.zh.md: d10c1a14792c80b3608b3302958de4546b817cda diff --git a/packages/document/document-convert-libreoffice/package.json b/packages/document/document-convert-libreoffice/package.json deleted file mode 100644 index eb020306ef..0000000000 --- a/packages/document/document-convert-libreoffice/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-document-convert-libreoffice", - "description": "Host Office conversion with a declared native target engine or Node WASM", - "version": "0.1.6-alpha.1", - "publishConfig": { - "access": "public" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-convert-libreoffice" - }, - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts" - ], - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-convert": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-document-convert": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^" - }, - "dependencies": { - "@deepseek-ai/libreoffice-kit": "0.0.1", - "@deepseek-ai/schemastery": "workspace:^" - } -} diff --git a/packages/document/document-convert/README.md b/packages/document/document-convert/README.md deleted file mode 100644 index fbc98feef3..0000000000 --- a/packages/document/document-convert/README.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -description: "Authorized Office input and complete PDF output for Host conversion providers." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-document-convert - -English | [中文](README.zh.md) - -## Summary - -Convert authorized `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, and `.pptx` bytes to a complete PDF. Callers receive PDF bytes and, for OOXML inputs, missing-font families without changing the original file or adding model context. - -## 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 [LibreOffice provider](../document-convert-libreoffice/README.md) to supply `ctx.documentConvert`. The abstract service has no mountable implementation or configuration. Callers authorize and stat their source before passing its identity, version, optional byte size, deferred bounded read, Office extension, and scheduling priority to `convert()`. The provider admits metadata before calling the read callback. A changed source version rejects conversion. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -Providers return independent caller-owned PDF bytes, a conversion cache key, and their rendering generation. Consumers check `generation` before reusing cached PDFs; configuration replacement creates a new generation. Individual reader cancellation rejects promptly; provider disposal waits for actual reads, conversion, and scratch cleanup. Cancellation rejects with its reason; classified conversion failures use `DocumentConvertError`. Providers and consumers share this package as a peer dependency so `instanceof DocumentConvertError` recognizes their failures. No runtime invariant companion is published because this service declares operations without an independent retained observation. - -
- ------ - - -## Further Exploration - -- [Document conversion](../../../docs/subsystems/document-convert.md) — composition and ownership. -- [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. - ------ - - -## Model Experience - -None, as this package converts bytes without model-facing tools, messages, or Session events. - -#### KV Cache effect - -None; conversion does not construct or modify model requests. - -## Known Limitations and Deferred Work - - - -- The service accepts only the six listed Office formats; source authorization belongs to consumers, while shared conversion reuse belongs to the provider. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/document/document-convert/README.zh.md b/packages/document/document-convert/README.zh.md deleted file mode 100644 index 3fa1865203..0000000000 --- a/packages/document/document-convert/README.zh.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -description: "宿主转换提供方使用的已授权 Office 输入与完整 PDF 输出。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-document-convert - -[English](README.md) | 中文 - -## 概述 - -将已授权的 `.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt` 和 `.pptx` 字节转换为完整 PDF。调用方获得 PDF 字节;OOXML 输入还提供缺失字体名称,不修改原文件或添加模型上下文。 - -## 目录 - -- [使用此包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [延伸阅读](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延后工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用此包 - -挂载 [LibreOffice 提供方](../document-convert-libreoffice/README.zh.md)以提供 `ctx.documentConvert`。抽象服务没有可挂载实现或配置。调用方须先授权并 stat 源文件,再向 `convert()` 传入标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。提供方在调用读取回调前准入元数据。源版本变化时拒绝转换。 - ------ - - -## 理解实现 - -
-实现内部细节 — 点击展开 - -提供方返回各调用方独立拥有的 PDF 字节、转换缓存键和转换 generation。消费者在复用缓存 PDF 前检查 `generation`;替换配置会创建新的 generation。单个读取方取消时立即拒绝;提供方销毁会等待实际读取、转换和临时文件清理。取消以其原因拒绝;已分类的转换失败使用 `DocumentConvertError`。提供方和消费者通过 peer dependency 共享此包,使 `instanceof DocumentConvertError` 能识别双方的失败。此服务仅声明操作,没有独立保留的观测,因此不发布运行时不变量伴随入口。 - -
- ------ - - -## 延伸阅读 - -- [文档转换](../../../docs/subsystems/document-convert.zh.md) — 组合和所有权。 -- [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 - ------ - - -## 模型体验 - -无,因为此包仅转换字节,不提供面向模型的工具、消息或 Session 事件。 - -#### KV Cache effect - -无;转换不会构造或修改模型请求。 - -## 已知限制与延后工作 - - - -- 服务仅接受上述六种 Office 格式;源文件授权由消费者负责,共享转换复用由提供方负责。 - - -### 开发备注 - -
-维护者工作上下文 — 点击展开 - -无。 - -
diff --git a/packages/document/document-convert/src/index.ts b/packages/document/document-convert/src/index.ts deleted file mode 100644 index 014e71c280..0000000000 --- a/packages/document/document-convert/src/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** Host document conversion: authorized Office bytes become a complete PDF. */ -import { Context, Service } from '@deepseek-ai/cordis' -import type { DocumentConvertErrorCode, DocumentConvertRequest, DocumentConvertResult, DocumentConverterGeneration } from './types.ts' - -export * from './types.ts' - -/** Classified conversion failure; engine details stay in the cause. */ -export class DocumentConvertError extends Error { - /** - * @param code - category suitable for a conversion consumer. - * @param message - diagnostic explaining the failed conversion. - * @param options - underlying engine or filesystem failure. - */ - constructor(readonly code: DocumentConvertErrorCode, message: string, options?: ErrorOptions) { - super(message, options) - this.name = 'DocumentConvertError' - } -} - -declare module '@deepseek-ai/cordis' { - interface Context { - /** One Host converter for already-authorized Office bytes. */ - documentConvert: DocumentConverter - } -} - -/** Load one provider subclass per context; consumers own source authorization. */ -export abstract class DocumentConverter extends Service { - constructor(ctx: Context) { super(ctx, 'documentConvert') } - - /** Changes whenever engine, font, or conversion configuration is replaced. */ - abstract readonly generation: DocumentConverterGeneration - - /** - * Convert Office bytes without modifying the source or writing Session events. - * @param request - authorized metadata and deferred bounded source read. - * @param signal - caller cancellation; provider disposal also stops active work. - * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. - * @throws {DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. - */ - abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise -} - -export default DocumentConverter diff --git a/packages/document/document-convert/src/types.ts b/packages/document/document-convert/src/types.ts deleted file mode 100644 index 35f63911ac..0000000000 --- a/packages/document/document-convert/src/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** Authorized Office input and complete PDF output, independent of the conversion engine. */ -import { brandString, type Branded } from '@deepseek-ai/dsh-brand' - -/** Binary Office and Office Open XML formats supported by document preview. */ -export type DocumentExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' - -/** Authorized execution scope and canonical source path, encoded by the consumer. */ -export type DocumentSourceKey = Branded<'DocumentSourceKey'> -/** - * Label an authorized source locator for pre-read deduplication. - * @param key - unambiguous encoding of authorization scope, execution world, and canonical path. - * @returns branded source locator; source authorization remains the caller's responsibility. - */ -export function DocumentSourceKey(key: string): DocumentSourceKey { return brandString(key) } - -/** One provider lifetime, including its engine, rendering settings, and font configuration. */ -export type DocumentConverterGeneration = Branded<'DocumentConverterGeneration'> -/** - * Label a provider lifetime. - * @param value - unique generation created by the provider. - * @returns branded converter generation. - */ -export function DocumentConverterGeneration(value: string): DocumentConverterGeneration { - return brandString(value) -} - -/** Provider generation and source-content digest; consumers must not parse it. */ -export type DocumentConvertKey = Branded<'DocumentConvertKey'> -/** - * Label a converter-owned content identity. - * @param value - generation and content identity created by the provider. - * @returns branded conversion identity. - */ -export function DocumentConvertKey(value: string): DocumentConvertKey { return brandString(value) } - -/** Foreground previews and explicit QA precede speculative background conversion. */ -export type DocumentConvertPriority = 'foreground' | 'background' - -/** Source authorization and metadata lookup must finish before submitting a request. */ -export interface DocumentConvertRequest { - readonly extension: DocumentExtension - readonly priority: DocumentConvertPriority - readonly source: { - readonly key: DocumentSourceKey - readonly version: string - /** Authorized stat size; omission reserves the provider's entire input limit. */ - readonly bytes?: number - /** - * Read only after provider admission; do not capture already-buffered input in queued production requests. - * @param signal - shared conversion lifetime, independent of an individual reader. - * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte. - * @returns owned bytes and the actual read version; a changed version rejects conversion. - */ - read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }> - } -} - -/** Successful conversion; failed and interrupted conversions reject instead. */ -export interface DocumentConvertResult { - /** Caller-owned complete PDF, valid after provider disposal. */ - readonly pdf: Uint8Array - /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ - readonly missingFonts: string[] - readonly cacheKey: DocumentConvertKey - readonly generation: DocumentConverterGeneration -} - -/** Failures a conversion consumer can present without exposing engine diagnostics. */ -export type DocumentConvertErrorCode = - | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' - | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-convert/tsconfig.json b/packages/document/document-convert/tsconfig.json deleted file mode 100644 index 85055ef176..0000000000 --- a/packages/document/document-convert/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../util/brand" - } - ] -} diff --git a/packages/document/document-convert/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml similarity index 69% rename from packages/document/document-convert/README.i18n.yaml rename to packages/document/office-to-pdf/README.i18n.yaml index b0aec675b0..b0019ba621 100644 --- a/packages/document/document-convert/README.i18n.yaml +++ b/packages/document/office-to-pdf/README.i18n.yaml @@ -1,6 +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/document/document-convert/README.md -README.md: fbc98feef30c4e32c255b82f57ea1aad5a68940d -README.zh.md: 3fa18652036d4823d45e3ccc38d03f599b3c4326 +# pnpm run verify-translation-pairing --write packages/document/office-to-pdf/README.md +README.md: 75f26612e649a4722543505cca9956541f07007b +README.zh.md: d9ecfb7347d22952b6100324926163649389773d diff --git a/packages/document/document-convert-libreoffice/README.md b/packages/document/office-to-pdf/README.md similarity index 85% rename from packages/document/document-convert-libreoffice/README.md rename to packages/document/office-to-pdf/README.md index c083215ea2..75f26612e6 100644 --- a/packages/document/document-convert-libreoffice/README.md +++ b/packages/document/office-to-pdf/README.md @@ -3,7 +3,7 @@ description: "Host Office conversion with the independently published LibreOffic kind: "package-reference" --- -# @deepseek-ai/dsh-document-convert-libreoffice +# @deepseek-ai/dsh-office-to-pdf English | [中文](README.zh.md) @@ -25,7 +25,9 @@ Convert Office documents to PDFs on the Host computer. Targets with a declared n ## Use this package -The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `document-convert`. Independent compositions mount `@deepseek-ai/dsh-document-convert-libreoffice` as a `cordis.yml` row. +The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `office-to-pdf`. Independent compositions mount `@deepseek-ai/dsh-office-to-pdf` as a `cordis.yml` row. + +Callers submit authorized source identity, version, optional byte size, a deferred bounded read, Office extension, and scheduling priority through `ctx.officeToPdf.convert()`. A changed source version rejects conversion. Results contain caller-owned PDF bytes, missing fonts, a cache key, and a conversion generation that changes on configuration replacement. Cancellation rejects with its reason; conversion failures use `OfficeToPdfError`. The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. @@ -38,7 +40,7 @@ The provider depends on the independently published [`@deepseek-ai/libreoffice-k | `maxImageResolution` | `192` | Maximum raster-image DPI; overrides the kit default of `144`. | | `fontFallbacks` | Kit defaults | Ordered font-family preference groups; each group requires at least two names containing non-whitespace characters. | -The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-document-convert-libreoffice) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. +The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-office-to-pdf) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. The provider retains successful PDFs by converter generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready. @@ -65,7 +67,7 @@ Each concurrent slot lazily creates and reuses one kit converter. The provider w ## Further Exploration -- [Rendering service](../document-convert/README.md) — input and result ownership. +- [Office to PDF](../../../docs/subsystems/office-to-pdf.md) — composition and input/result ownership. - [Workspace Files](../../api/workspace-files/README.md) — Session file authorization and bounded reads. ----- diff --git a/packages/document/document-convert-libreoffice/README.zh.md b/packages/document/office-to-pdf/README.zh.md similarity index 84% rename from packages/document/document-convert-libreoffice/README.zh.md rename to packages/document/office-to-pdf/README.zh.md index d10c1a1479..d9ecfb7347 100644 --- a/packages/document/document-convert-libreoffice/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -3,7 +3,7 @@ description: "通过独立发布的 LibreOffice kit 在 Host 转换 Office 文 kind: "package-reference" --- -# @deepseek-ai/dsh-document-convert-libreoffice +# @deepseek-ai/dsh-office-to-pdf [English](README.md) | 中文 @@ -25,7 +25,9 @@ kind: "package-reference" ## 使用此包 -[Web bundle](../../bundle/web-app/README.zh.md)以 `document-convert` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-document-convert-libreoffice`。 +[Web bundle](../../bundle/web-app/README.zh.md)以 `office-to-pdf` 条目挂载此提供方。独立组合通过 `cordis.yml` 条目挂载 `@deepseek-ai/dsh-office-to-pdf`。 + +调用方通过 `ctx.officeToPdf.convert()` 提交已授权源的标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。源版本变化会拒绝转换。结果包含调用方拥有的 PDF 字节、缺失字体、缓存键和转换 generation;配置替换后 generation 随之改变。取消以原因为拒绝值,转换失败使用 `OfficeToPdfError`。 此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 @@ -38,7 +40,7 @@ kind: "package-reference" | `maxImageResolution` | `192` | 最大光栅图像 DPI;覆盖 kit 的默认值 `144`。 | | `fontFallbacks` | kit 默认值 | 有序字体族优先组;每组至少包含两个含非空白字符的名称。 | -[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-document-convert-libreoffice)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-office-to-pdf)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 @@ -65,7 +67,7 @@ kind: "package-reference" ## 延伸阅读 -- [渲染服务](../document-convert/README.zh.md) — 输入和结果所有权。 +- [Office 转 PDF](../../../docs/subsystems/office-to-pdf.zh.md) — 组合与输入、结果所有权。 - [工作区文件](../../api/workspace-files/README.zh.md) — Session 文件授权与有界读取。 ----- diff --git a/packages/document/document-convert/package.json b/packages/document/office-to-pdf/package.json similarity index 62% rename from packages/document/document-convert/package.json rename to packages/document/office-to-pdf/package.json index bb7805f3cc..e31095398f 100644 --- a/packages/document/document-convert/package.json +++ b/packages/document/office-to-pdf/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-document-convert", - "description": "Authorized Office bytes to PDF rendering service", + "name": "@deepseek-ai/dsh-office-to-pdf", + "description": "Shared Office-to-PDF conversion with bounded queues and caching", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -12,19 +12,19 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/document/document-convert" + "directory": "packages/document/office-to-pdf" }, "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json", "./types": { "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" + } }, "files": [ "lib/index.js", @@ -35,9 +35,13 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^" }, "dependencies": { + "@deepseek-ai/libreoffice-kit": "0.0.1", + "@deepseek-ai/schemastery": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^" } } diff --git a/packages/document/office-to-pdf/src/errors.ts b/packages/document/office-to-pdf/src/errors.ts new file mode 100644 index 0000000000..838bd679c4 --- /dev/null +++ b/packages/document/office-to-pdf/src/errors.ts @@ -0,0 +1,15 @@ +/** Classified failures shared by conversion and its consumers. */ +import type { OfficeToPdfErrorCode } from './types.ts' + +/** Classified conversion failure; engine details stay in the cause. */ +export class OfficeToPdfError extends Error { + /** + * @param code - category suitable for a conversion consumer. + * @param message - diagnostic explaining the failed conversion. + * @param options - underlying engine or filesystem failure. + */ + constructor(readonly code: OfficeToPdfErrorCode, message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'OfficeToPdfError' + } +} diff --git a/packages/document/office-to-pdf/src/identity.ts b/packages/document/office-to-pdf/src/identity.ts new file mode 100644 index 0000000000..313b3e3e2f --- /dev/null +++ b/packages/document/office-to-pdf/src/identity.ts @@ -0,0 +1,31 @@ +/** Source locators and converter-owned identities for shared PDF reuse. */ +import { brandString, type Branded } from '@deepseek-ai/dsh-brand' + +/** Authorized execution scope and canonical source path, encoded by the consumer. */ +export type OfficeSourceKey = Branded<'OfficeSourceKey'> +/** + * Label an authorized source locator for pre-read deduplication. + * @param key - unambiguous encoding of authorization scope, execution world, and canonical path. + * @returns branded source locator; source authorization remains the caller's responsibility. + */ +export function OfficeSourceKey(key: string): OfficeSourceKey { return brandString(key) } + +/** One provider lifetime, including its engine, rendering settings, and font configuration. */ +export type OfficeToPdfGeneration = Branded<'OfficeToPdfGeneration'> +/** + * Label a provider lifetime. + * @param value - unique generation created by the provider. + * @returns branded converter generation. + */ +export function OfficeToPdfGeneration(value: string): OfficeToPdfGeneration { + return brandString(value) +} + +/** Provider generation and source-content digest; consumers must not parse it. */ +export type OfficeToPdfKey = Branded<'OfficeToPdfKey'> +/** + * Label a converter-owned content identity. + * @param value - generation and content identity created by the provider. + * @returns branded conversion identity. + */ +export function OfficeToPdfKey(value: string): OfficeToPdfKey { return brandString(value) } diff --git a/packages/document/document-convert-libreoffice/src/index.ts b/packages/document/office-to-pdf/src/index.ts similarity index 79% rename from packages/document/document-convert-libreoffice/src/index.ts rename to packages/document/office-to-pdf/src/index.ts index 3f4ef308c2..f585fc3184 100644 --- a/packages/document/document-convert-libreoffice/src/index.ts +++ b/packages/document/office-to-pdf/src/index.ts @@ -3,13 +3,26 @@ import { randomUUID } from 'node:crypto' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join } from 'node:path' -import type { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { createConverter, type Converter, type ConverterOptions } from '@deepseek-ai/libreoffice-kit' -import { DocumentConverter, DocumentConvertError, DocumentConverterGeneration, type DocumentExtension, type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' import z from '@deepseek-ai/schemastery' +import { OfficeToPdfError } from './errors.ts' +import { OfficeToPdfGeneration } from './identity.ts' +import type { OfficeExtension, OfficeToPdfRequest, OfficeToPdfResult } from './types.ts' import { readPdf } from './output.ts' import { ConversionQueue } from './queue.ts' +export * from './errors.ts' +export * from './identity.ts' +export * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Shared Host conversion of already-authorized Office bytes to PDF. */ + officeToPdf: OfficeToPdf + } +} + /** Provider concurrency and kit rendering/font configuration. */ export interface Config { /** Maximum simultaneous conversions; queued callers remain cancellable. */ @@ -81,9 +94,10 @@ interface Slot { } /** A provider lifetime owns all converters, queued calls, and temporary files. */ -export class LibreOfficeConverter extends DocumentConverter { +export class OfficeToPdf extends Service { static Config = Config - readonly generation = DocumentConverterGeneration(randomUUID()) + /** Changes whenever engine, font, or conversion configuration is replaced. */ + readonly generation: OfficeToPdfGeneration = OfficeToPdfGeneration(randomUUID()) private readonly slots: Slot[] = [] private readonly queue: ConversionQueue private readonly options: ConverterOptions @@ -93,7 +107,7 @@ export class LibreOfficeConverter extends DocumentConverter { * @param config - resolved rendering, font, and concurrency limits. */ constructor(ctx: Context, private readonly config: Config) { - super(ctx) + super(ctx, 'officeToPdf') if (config.fontDirectories?.some(path => !isAbsolute(path))) throw new Error('fontDirectories must contain absolute paths.') if (config.maxSourceBytes < config.maxInputBytes) throw new Error('maxSourceBytes must be at least maxInputBytes.') const { fontDirectories, fontFallbacks, timeoutMs, maxInputBytes, maxOutputBytes, maxImageResolution, @@ -115,11 +129,18 @@ export class LibreOfficeConverter extends DocumentConverter { }) } - convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise { + /** + * Convert Office bytes without modifying the source or writing Session events. + * @param request - authorized metadata and deferred bounded source read. + * @param signal - caller cancellation; provider disposal also stops active work. + * @returns caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently. + * @throws {OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason. + */ + convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise { return this.queue.read(request, signal) } - private async convertBytes(bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal): Promise> { + private async convertBytes(bytes: Uint8Array, extension: OfficeExtension, signal: AbortSignal): Promise> { signal.throwIfAborted() let slot = this.slots.find(candidate => !candidate.busy) if (slot === undefined) { slot = { busy: false }; this.slots.push(slot) } @@ -134,7 +155,7 @@ export class LibreOfficeConverter extends DocumentConverter { } const converter = await slot.converter signal.throwIfAborted() - directory = await mkdtemp(join(tmpdir(), 'dsh-document-convert-')) + directory = await mkdtemp(join(tmpdir(), 'dsh-office-to-pdf-')) const inputPath = join(directory, `source.${extension}`) const outputPath = join(directory, 'converted.pdf') await writeFile(inputPath, bytes, { flag: 'wx', mode: 0o600, signal }) @@ -144,20 +165,20 @@ export class LibreOfficeConverter extends DocumentConverter { let pdf: Uint8Array try { pdf = await readPdf(outputPath, this.config.maxOutputBytes, signal) } catch (cause) { - if (cause instanceof DocumentConvertError) throw cause - throw new DocumentConvertError('invalid-output', 'The converter PDF could not be read.', { cause }) + if (cause instanceof OfficeToPdfError) throw cause + throw new OfficeToPdfError('invalid-output', 'The converter PDF could not be read.', { cause }) } signal.throwIfAborted() return { pdf, missingFonts: result.missingFonts } } catch (cause) { signal.throwIfAborted() - if (cause instanceof DocumentConvertError) throw cause + if (cause instanceof OfficeToPdfError) throw cause const code = typeof cause === 'object' && cause !== null && 'code' in cause ? cause.code : undefined switch (code) { case 'input-too-large': case 'output-too-large': case 'invalid-document': case 'unsupported-format': case 'invalid-output': case 'timeout': case 'unavailable': - throw new DocumentConvertError(code, 'LibreOffice conversion failed.', { cause }) - default: throw new DocumentConvertError('failed', 'LibreOffice conversion failed.', { cause }) + throw new OfficeToPdfError(code, 'LibreOffice conversion failed.', { cause }) + default: throw new OfficeToPdfError('failed', 'LibreOffice conversion failed.', { cause }) } } finally { try { if (directory !== undefined) await rm(directory, { recursive: true, force: true }) } @@ -166,4 +187,4 @@ export class LibreOfficeConverter extends DocumentConverter { } } -export default LibreOfficeConverter +export default OfficeToPdf diff --git a/packages/document/document-convert-libreoffice/src/output.ts b/packages/document/office-to-pdf/src/output.ts similarity index 66% rename from packages/document/document-convert-libreoffice/src/output.ts rename to packages/document/office-to-pdf/src/output.ts index 41dee85ee2..46f79d7d19 100644 --- a/packages/document/document-convert-libreoffice/src/output.ts +++ b/packages/document/office-to-pdf/src/output.ts @@ -1,7 +1,7 @@ /** Read only the bounded, regular PDF created inside a private task directory. */ import { constants } from 'node:fs' import { lstat, open } from 'node:fs/promises' -import { DocumentConvertError } from '@deepseek-ai/dsh-document-convert' +import { OfficeToPdfError } from './errors.ts' /** * Refuse missing, link-shaped, oversized, truncated, and non-PDF output. @@ -13,12 +13,12 @@ import { DocumentConvertError } from '@deepseek-ai/dsh-document-convert' export async function readPdf(path: string, limit: number, signal: AbortSignal): Promise { signal.throwIfAborted() const entry = await lstat(path) - if (!entry.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') + if (!entry.isFile()) throw new OfficeToPdfError('invalid-output', 'The converter output is not a regular file.') const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) try { const info = await file.stat() - if (!info.isFile()) throw new DocumentConvertError('invalid-output', 'The converter output is not a regular file.') - if (info.size > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (!info.isFile()) throw new OfficeToPdfError('invalid-output', 'The converter output is not a regular file.') + if (info.size > limit) throw new OfficeToPdfError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const bytes = Buffer.alloc(info.size + 1) let length = 0 while (length < bytes.length) { @@ -28,11 +28,11 @@ export async function readPdf(path: string, limit: number, signal: AbortSignal): length += read.bytesRead } signal.throwIfAborted() - if (length > limit) throw new DocumentConvertError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') + if (length > limit) throw new OfficeToPdfError('output-too-large', 'The converted PDF exceeds maxOutputBytes.') const pdf = bytes.subarray(0, length) if (length !== info.size || !pdf.subarray(0, 8).toString('ascii').match(/^%PDF-\d\.\d/u) || !pdf.subarray(-1024).toString('ascii').trimEnd().endsWith('%%EOF')) { - throw new DocumentConvertError('invalid-output', 'The converter did not produce a complete PDF.') + throw new OfficeToPdfError('invalid-output', 'The converter did not produce a complete PDF.') } return pdf } finally { await file.close() } diff --git a/packages/document/document-convert-libreoffice/src/queue.ts b/packages/document/office-to-pdf/src/queue.ts similarity index 82% rename from packages/document/document-convert-libreoffice/src/queue.ts rename to packages/document/office-to-pdf/src/queue.ts index 6ed9b0f487..6270512e07 100644 --- a/packages/document/document-convert-libreoffice/src/queue.ts +++ b/packages/document/office-to-pdf/src/queue.ts @@ -1,34 +1,35 @@ /** Bounded source admission, shared content conversion, and caller-owned PDF delivery. */ import { createHash } from 'node:crypto' -import { DocumentConvertError, DocumentConvertKey, type DocumentExtension, type DocumentConverterGeneration, - type DocumentConvertRequest, type DocumentConvertResult } from '@deepseek-ai/dsh-document-convert' +import { OfficeToPdfError } from './errors.ts' +import { OfficeToPdfKey, type OfficeToPdfGeneration } from './identity.ts' +import type { OfficeExtension, OfficeToPdfRequest, OfficeToPdfResult } from './types.ts' import type { Config } from './index.ts' -type Converted = Pick -type Convert = (bytes: Uint8Array, extension: DocumentExtension, signal: AbortSignal) => Promise +type Converted = Pick +type Convert = (bytes: Uint8Array, extension: OfficeExtension, signal: AbortSignal) => Promise interface Reader { job: Job readonly source: string - readonly priority: DocumentConvertRequest['priority'] - readonly resolve: (value: DocumentConvertResult) => void + readonly priority: OfficeToPdfRequest['priority'] + readonly resolve: (value: OfficeToPdfResult) => void readonly reject: (error: unknown) => void readonly cleanup: () => void } interface Job { - readonly request: DocumentConvertRequest + readonly request: OfficeToPdfRequest readonly controller: AbortController readonly readers: Set readonly sources: Set - priority: DocumentConvertRequest['priority'] + priority: OfficeToPdfRequest['priority'] state: 'queued' | 'running' | 'finished' } /** One converter generation owns every queued source, conversion, reader, and retained PDF. */ export class ConversionQueue { - private readonly ready = new Map() - private readonly aliases = new Map() + private readonly ready = new Map() + private readonly aliases = new Map() private readonly sources = new Map() - private readonly digests = new Map() + private readonly digests = new Map() private readonly queue: Job[] = [] private readonly tasks = new Set>() private readonly jobs = new Set() @@ -44,7 +45,7 @@ export class ConversionQueue { * @param generation - provider lifetime; prevents reuse after engine or font replacement. * @param convert - executes one admitted conversion and settles after scratch cleanup. */ - constructor(private readonly config: Config, private readonly generation: DocumentConverterGeneration, + constructor(private readonly config: Config, private readonly generation: OfficeToPdfGeneration, private readonly convert: Convert) {} /** @@ -53,11 +54,11 @@ export class ConversionQueue { * @param signal - this reader's cancellation; the final reader cancels shared work. * @returns independent PDF bytes; busy or canceled readers reject without releasing active engine capacity early. */ - async read(request: DocumentConvertRequest, signal?: AbortSignal): Promise { + async read(request: OfficeToPdfRequest, signal?: AbortSignal): Promise { signal?.throwIfAborted() if (this.disposed) throw this.unavailable() if (request.source.bytes !== undefined && request.source.bytes > this.config.maxInputBytes) { - throw new DocumentConvertError('input-too-large', 'The Office source exceeds maxInputBytes.') + throw new OfficeToPdfError('input-too-large', 'The Office source exceeds maxInputBytes.') } const source = JSON.stringify([request.source.key, request.source.version, request.extension]) const alias = this.aliases.get(source) @@ -87,7 +88,7 @@ export class ConversionQueue { const shared = job if (request.priority === 'foreground') shared.priority = 'foreground' this.readers++ - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { const abort = (): void => { this.release(reader) const reason: unknown = signal?.reason @@ -114,9 +115,9 @@ export class ConversionQueue { await Promise.allSettled(this.tasks) } - private busy(): DocumentConvertError { return new DocumentConvertError('busy', 'The document converter has reached its admission limit.') } - private unavailable(): DocumentConvertError { return new DocumentConvertError('unavailable', 'The document converter is unavailable.') } - private copy(result: DocumentConvertResult): DocumentConvertResult { + private busy(): OfficeToPdfError { return new OfficeToPdfError('busy', 'The document converter has reached its admission limit.') } + private unavailable(): OfficeToPdfError { return new OfficeToPdfError('unavailable', 'The document converter is unavailable.') } + private copy(result: OfficeToPdfResult): OfficeToPdfResult { return { ...result, pdf: Uint8Array.from(result.pdf), missingFonts: [...result.missingFonts] } } private release(reader: Reader): void { @@ -177,10 +178,10 @@ export class ConversionQueue { signal.throwIfAborted() const input = await job.request.source.read(signal, reserved) signal.throwIfAborted() - if (input.version !== job.request.source.version) throw new DocumentConvertError('source-changed', 'The source changed while waiting for conversion.') - if (input.bytes.byteLength > reserved) throw new DocumentConvertError('input-too-large', 'The source exceeds its reserved read capacity.') + if (input.version !== job.request.source.version) throw new OfficeToPdfError('source-changed', 'The source changed while waiting for conversion.') + if (input.bytes.byteLength > reserved) throw new OfficeToPdfError('input-too-large', 'The source exceeds its reserved read capacity.') const digest = createHash('sha256').update(job.request.extension).update('\0').update(input.bytes).digest('hex') - const key = DocumentConvertKey(`${this.generation}:${digest}`) + const key = OfficeToPdfKey(`${this.generation}:${digest}`) const cached = this.ready.get(key) if (cached !== undefined) { this.ready.delete(key) @@ -207,7 +208,7 @@ export class ConversionQueue { } finally { if (this.digests.get(key) === job) this.digests.delete(key) } } - private finish(job: Job, result: DocumentConvertResult): void { + private finish(job: Job, result: OfficeToPdfResult): void { for (const source of job.sources) this.sources.delete(source) if (this.ready.has(result.cacheKey)) { for (const source of job.sources) { @@ -218,10 +219,10 @@ export class ConversionQueue { } for (const reader of job.readers) { this.release(reader); reader.resolve(this.copy(result)) } } - private retain(result: DocumentConvertResult): void { + private retain(result: OfficeToPdfResult): void { if (result.pdf.byteLength > this.config.maxCachedBytes) return while (this.ready.size >= this.config.maxCachedEntries || this.cachedBytes + result.pdf.byteLength > this.config.maxCachedBytes) { - const [key, oldest] = this.ready.entries().next().value as [DocumentConvertKey, DocumentConvertResult] + const [key, oldest] = this.ready.entries().next().value as [OfficeToPdfKey, OfficeToPdfResult] this.ready.delete(key) this.cachedBytes -= oldest.pdf.byteLength for (const [source, digest] of this.aliases) if (digest === key) this.aliases.delete(source) diff --git a/packages/document/office-to-pdf/src/types.ts b/packages/document/office-to-pdf/src/types.ts new file mode 100644 index 0000000000..58d015152e --- /dev/null +++ b/packages/document/office-to-pdf/src/types.ts @@ -0,0 +1,43 @@ +/** Authorized Office input and complete PDF output, for the shared Host converter. */ +import type { OfficeSourceKey, OfficeToPdfGeneration, OfficeToPdfKey } from './identity.ts' +export type { OfficeSourceKey, OfficeToPdfGeneration, OfficeToPdfKey } from './identity.ts' + +/** Binary Office and Office Open XML formats supported by Office-to-PDF conversion. */ +export type OfficeExtension = 'doc' | 'docx' | 'xls' | 'xlsx' | 'ppt' | 'pptx' + +/** Foreground previews and explicit QA precede speculative background conversion. */ +export type OfficeToPdfPriority = 'foreground' | 'background' + +/** Source authorization and metadata lookup must finish before submitting a request. */ +export interface OfficeToPdfRequest { + readonly extension: OfficeExtension + readonly priority: OfficeToPdfPriority + readonly source: { + readonly key: OfficeSourceKey + readonly version: string + /** Authorized stat size; omission reserves the provider's entire input limit. */ + readonly bytes?: number + /** + * Read only after provider admission; do not capture already-buffered input in queued production requests. + * @param signal - shared conversion lifetime, independent of an individual reader. + * @param maxBytes - reserved source capacity; read at most this plus one overflow sentinel byte. + * @returns owned bytes and the actual read version; a changed version rejects conversion. + */ + read(signal: AbortSignal, maxBytes: number): Promise<{ readonly bytes: Uint8Array; readonly version: string }> + } +} + +/** Successful conversion; failed and interrupted conversions reject instead. */ +export interface OfficeToPdfResult { + /** Caller-owned complete PDF, valid after provider disposal. */ + readonly pdf: Uint8Array + /** Requested OOXML font families unavailable to this conversion; binary Office formats return an empty list. */ + readonly missingFonts: string[] + readonly cacheKey: OfficeToPdfKey + readonly generation: OfficeToPdfGeneration +} + +/** Failures a conversion consumer can present without exposing engine diagnostics. */ +export type OfficeToPdfErrorCode = + | 'input-too-large' | 'output-too-large' | 'invalid-document' | 'unsupported-format' + | 'invalid-output' | 'timeout' | 'unavailable' | 'failed' | 'busy' | 'source-changed' diff --git a/packages/document/document-convert-libreoffice/tests/output.spec.ts b/packages/document/office-to-pdf/tests/output.spec.ts similarity index 100% rename from packages/document/document-convert-libreoffice/tests/output.spec.ts rename to packages/document/office-to-pdf/tests/output.spec.ts diff --git a/packages/document/document-convert-libreoffice/tests/provider.spec.ts b/packages/document/office-to-pdf/tests/provider.spec.ts similarity index 93% rename from packages/document/document-convert-libreoffice/tests/provider.spec.ts rename to packages/document/office-to-pdf/tests/provider.spec.ts index d84e459ced..216b9e1f4c 100644 --- a/packages/document/document-convert-libreoffice/tests/provider.spec.ts +++ b/packages/document/office-to-pdf/tests/provider.spec.ts @@ -1,23 +1,22 @@ /** Disk output, resource bounds, and cancellation around the external kit. */ import { access, mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' -import { DocumentSourceKey, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { Context } from '@deepseek-ai/cordis' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import LibreOfficeConverter, { Config } from '../src/index.ts' +import OfficeToPdf, { Config, OfficeSourceKey, type OfficeToPdfRequest } from '../src/index.ts' const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') const input = new Uint8Array([80, 75, 3, 4]) -const request: DocumentConvertRequest = { extension: 'docx', priority: 'foreground', source: { - key: DocumentSourceKey('source'), version: 'v1', bytes: input.length, +const request: OfficeToPdfRequest = { extension: 'docx', priority: 'foreground', source: { + key: OfficeSourceKey('source'), version: 'v1', bytes: input.length, read: async () => ({ bytes: input, version: 'v1' }), } } -function distinct(index: number): DocumentConvertRequest { - return { ...request, source: { ...request.source, key: DocumentSourceKey(`source-${index}`), +function distinct(index: number): OfficeToPdfRequest { + return { ...request, source: { ...request.source, key: OfficeSourceKey(`source-${index}`), read: async () => ({ bytes: new Uint8Array([80, 75, 3, index]), version: 'v1' }) } } } let ctx: Context @@ -35,9 +34,9 @@ beforeEach(() => { }) afterEach(async () => { await ctx.fiber.dispose() }) -async function mount(config: Partial = {}): Promise { - await ctx.plugin(LibreOfficeConverter, config) - return ctx.documentConvert as LibreOfficeConverter +async function mount(config: Partial = {}): Promise { + await ctx.plugin(OfficeToPdf, config) + return ctx.officeToPdf } it('preserves the kit font defaults when font configuration is omitted', async () => { @@ -231,12 +230,12 @@ it('rejects caller cancellation before allocating any conversion resources', asy }) it('rejects relative font directories during provider configuration', async () => { - expect(() => new LibreOfficeConverter(ctx, Config({ fontDirectories: ['relative/fonts'] }))) + expect(() => new OfficeToPdf(ctx, Config({ fontDirectories: ['relative/fonts'] }))) .toThrow('fontDirectories must contain absolute paths') }) it('rejects source capacity below one permitted input before allocating a converter', () => { - expect(() => new LibreOfficeConverter(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) + expect(() => new OfficeToPdf(ctx, Config({ maxInputBytes: 4, maxSourceBytes: 3 }))) .toThrow('maxSourceBytes must be at least maxInputBytes') expect(kit.create).not.toHaveBeenCalled() }) diff --git a/packages/document/document-convert-libreoffice/tests/queue.spec.ts b/packages/document/office-to-pdf/tests/queue.spec.ts similarity index 97% rename from packages/document/document-convert-libreoffice/tests/queue.spec.ts rename to packages/document/office-to-pdf/tests/queue.spec.ts index ced4a3a646..7bfeedbaa9 100644 --- a/packages/document/document-convert-libreoffice/tests/queue.spec.ts +++ b/packages/document/office-to-pdf/tests/queue.spec.ts @@ -1,18 +1,17 @@ /** Controlled source and engine completions exercise admission and shared content ownership. */ -import { DocumentSourceKey, DocumentConverterGeneration, type DocumentConvertRequest } from '@deepseek-ai/dsh-document-convert' import { expect, it, onTestFinished, vi } from 'vitest' import { ConversionQueue } from '../src/queue.ts' -import { Config } from '../src/index.ts' +import { Config, OfficeSourceKey, OfficeToPdfGeneration, type OfficeToPdfRequest } from '../src/index.ts' const output = { pdf: new Uint8Array([37, 80, 68, 70]), missingFonts: ['Font'] } -function source(key: string, byte = 1, priority: DocumentConvertRequest['priority'] = 'foreground') { - const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) - const request: DocumentConvertRequest = { extension: 'docx', priority, source: { key: DocumentSourceKey(key), version: 'v1', bytes: 1, read } } +function source(key: string, byte = 1, priority: OfficeToPdfRequest['priority'] = 'foreground') { + const read = vi.fn().mockResolvedValue({ bytes: new Uint8Array([byte]), version: 'v1' }) + const request: OfficeToPdfRequest = { extension: 'docx', priority, source: { key: OfficeSourceKey(key), version: 'v1', bytes: 1, read } } return { read, request } } function harness(config: Partial = {}) { const convert = vi.fn[2]>().mockResolvedValue(output) - const queue = new ConversionQueue(Config(config), DocumentConverterGeneration('test'), convert) + const queue = new ConversionQueue(Config(config), OfficeToPdfGeneration('test'), convert) onTestFinished(() => queue.dispose()) return { queue, convert } } @@ -336,7 +335,7 @@ it('separates Office extensions and converter generations in content identity', const h = harness(), a = source('a') const first = await h.queue.read(a.request) const otherFormat = await h.queue.read({ ...a.request, extension: 'pptx' }) - const other = new ConversionQueue(Config({}), DocumentConverterGeneration('replacement'), h.convert) + const other = new ConversionQueue(Config({}), OfficeToPdfGeneration('replacement'), h.convert) onTestFinished(() => other.dispose()) const replacement = await other.read(a.request) expect(first.cacheKey).not.toBe(otherFormat.cacheKey) diff --git a/packages/document/document-convert-libreoffice/tsconfig.json b/packages/document/office-to-pdf/tsconfig.json similarity index 89% rename from packages/document/document-convert-libreoffice/tsconfig.json rename to packages/document/office-to-pdf/tsconfig.json index 2199a71798..083e95a0ac 100644 --- a/packages/document/document-convert-libreoffice/tsconfig.json +++ b/packages/document/office-to-pdf/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../document-convert" + "path": "../../util/brand" } ] } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts index 7c76b2aea4..11aa009afe 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/libreoffice-kit.ts @@ -3,7 +3,7 @@ import { notAvailableError } from '../notImplementedFail.ts' /** * Refuse converter creation with the kit's unavailable error code. - * @returns A rejected promise consumed by the Host document-convert provider. + * @returns A rejected promise consumed by the Host office-to-pdf provider. */ export function createConverter(): Promise { return Promise.reject(Object.assign(notAvailableError('@deepseek-ai/libreoffice-kit', 'createConverter'), { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 446ca88582..fb2e9c3074 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -874,25 +874,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'documentConvert', - summary: 'Load one provider subclass per context; consumers own source authorization.', - description: 'Load one provider subclass per context; consumers own source authorization.', - methods: [ - { - signature: 'abstract readonly generation: DocumentConverterGeneration', - description: 'Changes whenever engine, font, or conversion configuration is replaced.', - parameters: [], - }, - { - signature: 'abstract convert(request: DocumentConvertRequest, signal?: AbortSignal): Promise', - description: 'Convert Office bytes without modifying the source or writing Session events.', - parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], - returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', - throws: ['{DocumentConvertError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], - }, - ], - }, { key: 'fileReferences', summary: 'Host capability for cancellable file-reference discovery.', @@ -1389,6 +1370,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'officeToPdf', + summary: 'A provider lifetime owns all converters, queued calls, and temporary files.', + description: 'A provider lifetime owns all converters, queued calls, and temporary files.', + methods: [ + { + signature: 'readonly generation: OfficeToPdfGeneration = OfficeToPdfGeneration(randomUUID())', + description: 'Changes whenever engine, font, or conversion configuration is replaced.', + parameters: [], + }, + { + signature: 'convert(request: OfficeToPdfRequest, signal?: AbortSignal): Promise', + description: 'Convert Office bytes without modifying the source or writing Session events.', + parameters: [{ name: 'request', description: 'authorized metadata and deferred bounded source read.' }, { name: 'signal', description: 'caller cancellation; provider disposal also stops active work.' }], + returns: 'caller-owned PDF bytes after conversion and scratch cleanup settle; canceled readers reject independently.', + throws: ['{OfficeToPdfError} Invalid input, unusable output, or engine failure; cancellation rejects with its reason.'], + }, + ], + }, { key: 'permissionPresets', summary: 'Owns the deployment\'s configured permission presets, the fixed Auto integration hook, and their write path.', @@ -4473,34 +4473,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryRegistrationHandle', declaration: 'export interface DirectoryRegistrationHandle {\n (): void;\n replace(entries: readonly LlmConfigurableProvider[]): void;\n}', }, - { - name: 'DocumentConverterGeneration', - declaration: 'export type DocumentConverterGeneration = Branded<\'DocumentConverterGeneration\'>;', - }, - { - name: 'DocumentConvertKey', - declaration: 'export type DocumentConvertKey = Branded<\'DocumentConvertKey\'>;', - }, - { - name: 'DocumentConvertPriority', - declaration: 'export type DocumentConvertPriority = \'foreground\' | \'background\';', - }, - { - name: 'DocumentConvertRequest', - declaration: 'export interface DocumentConvertRequest {\n readonly extension: DocumentExtension;\n readonly priority: DocumentConvertPriority;\n readonly source: {\n readonly key: DocumentSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', - }, - { - name: 'DocumentConvertResult', - declaration: 'export interface DocumentConvertResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: DocumentConvertKey;\n readonly generation: DocumentConverterGeneration;\n}', - }, - { - name: 'DocumentExtension', - declaration: 'export type DocumentExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', - }, - { - name: 'DocumentSourceKey', - declaration: 'export type DocumentSourceKey = Branded<\'DocumentSourceKey\'>;', - }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', @@ -5145,6 +5117,34 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', }, + { + name: 'OfficeExtension', + declaration: 'export type OfficeExtension = \'doc\' | \'docx\' | \'xls\' | \'xlsx\' | \'ppt\' | \'pptx\';', + }, + { + name: 'OfficeSourceKey', + declaration: 'export type OfficeSourceKey = Branded<\'OfficeSourceKey\'>;', + }, + { + name: 'OfficeToPdfGeneration', + declaration: 'export type OfficeToPdfGeneration = Branded<\'OfficeToPdfGeneration\'>;', + }, + { + name: 'OfficeToPdfKey', + declaration: 'export type OfficeToPdfKey = Branded<\'OfficeToPdfKey\'>;', + }, + { + name: 'OfficeToPdfPriority', + declaration: 'export type OfficeToPdfPriority = \'foreground\' | \'background\';', + }, + { + name: 'OfficeToPdfRequest', + declaration: 'export interface OfficeToPdfRequest {\n readonly extension: OfficeExtension;\n readonly priority: OfficeToPdfPriority;\n readonly source: {\n readonly key: OfficeSourceKey;\n readonly version: string;\n readonly bytes?: number;\n read(signal: AbortSignal, maxBytes: number): Promise<{\n readonly bytes: Uint8Array;\n readonly version: string;\n }>;\n };\n}', + }, + { + name: 'OfficeToPdfResult', + declaration: 'export interface OfficeToPdfResult {\n readonly pdf: Uint8Array;\n readonly missingFonts: string[];\n readonly cacheKey: OfficeToPdfKey;\n readonly generation: OfficeToPdfGeneration;\n}', + }, { name: 'OneShotSubagentDescriptorData', declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea1475a23..4c221b9294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2223,12 +2223,6 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner - '@deepseek-ai/dsh-document-convert': - specifier: workspace:^ - version: link:../../document/document-convert - '@deepseek-ai/dsh-document-convert-libreoffice': - specifier: workspace:^ - version: link:../../document/document-convert-libreoffice '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -2262,6 +2256,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-office-to-pdf': + specifier: workspace:^ + version: link:../../document/office-to-pdf '@deepseek-ai/dsh-session-log-export': specifier: workspace:^ version: link:../../session-query/session-log-export @@ -5960,18 +5957,11 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/document/document-convert: + packages/document/office-to-pdf: dependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - - packages/document/document-convert-libreoffice: - dependencies: '@deepseek-ai/libreoffice-kit': specifier: 0.0.1 version: 0.0.1 @@ -5988,9 +5978,6 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-document-convert': - specifier: workspace:^ - version: link:../document-convert packages/experimental/agent-team: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 47a0f41b83..3d8ccfa552 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,7 +63,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', - documentConvert: 'document-convert.md', + officeToPdf: 'office-to-pdf.md', attachments: 'attachment.md', shell: 'shell.md', shellEnv: 'shell.md', @@ -277,10 +277,10 @@ export const LINK_MAP: Readonly> = { PluginInstallProgress: 'boot.md', BrowserUseProviderName: 'browser-use.md', ComputerUseProviderName: 'computer-use.md', - DocumentConvertRequest: 'document-convert.md', - DocumentConvertResult: 'document-convert.md', - DocumentConvertPriority: 'document-convert.md', - DocumentConverterGeneration: 'document-convert.md', + OfficeToPdfRequest: 'office-to-pdf.md', + OfficeToPdfResult: 'office-to-pdf.md', + OfficeToPdfPriority: 'office-to-pdf.md', + OfficeToPdfGeneration: 'office-to-pdf.md', Agent: 'core.md', AgentCancelCause: 'core.md', AgentFactory: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index bf78760459..74697c02f5 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -151,8 +151,8 @@ const SERVICE_ROLES: ServiceRole[] = [ note: 'One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock.', }, { - key: 'documentConvert', pkg: 'document-convert', title: 'Office to PDF conversion', - mode: 'seam', implementations: ['document-convert-libreoffice'], consumers: [], + key: 'officeToPdf', pkg: 'office-to-pdf', title: 'Office to PDF conversion', + mode: 'core', consumers: [], note: 'Authorized Office bytes are converted on the Host using the declared native target engine, or Node WASM when no native target is declared.', }, { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 41cdfa3410..89eb88ad3e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -53,8 +53,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/browser-use/browser-use': { kind: 'none', reason: 'The registry only reserves a provider name; providers own browser tools and Session resources.' }, 'packages/experimental/browser-use-runtime': { kind: 'indirect', reason: 'Browser providers call the library to own resources and expose upstream MCP tools.' }, 'packages/computer-use/computer-use': { kind: 'none', reason: 'The registry only reserves provider names; providers own all model-facing tools and guidance.' }, - 'packages/document/document-convert': { kind: 'none', reason: 'Conversion callers own model tools and messages.' }, - 'packages/document/document-convert-libreoffice': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, + 'packages/document/office-to-pdf': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' }, 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index f47fdc83c7..bc751cdd57 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,7 +37,7 @@ "@deepseek-ai/dsh-experimental-browser-use-stagehand-native": ["./packages/experimental/browser-use-stagehand-native/src/index.ts"], "@deepseek-ai/dsh-experimental-browser-use-runtime/mcp": ["./packages/experimental/browser-use-runtime/src/mcp.ts"], - "@deepseek-ai/dsh-document-convert/types": ["./packages/document/document-convert/src/types.ts"], + "@deepseek-ai/dsh-office-to-pdf/types": ["./packages/document/office-to-pdf/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller": ["./packages/api/terminal-controller/src/index.ts"], "@deepseek-ai/dsh-api-terminal-controller/types": ["./packages/api/terminal-controller/src/types.ts"], "@deepseek-ai/dsh-api-terminal-controller/client": ["./packages/api/terminal-controller/src/client/index.ts"], @@ -326,8 +326,6 @@ "@deepseek-ai/dsh-credentials-local": ["./packages/credentials/credentials-local/src"], "@deepseek-ai/dsh-deepseek-llm-api-extensions": ["./packages/llm/deepseek-llm-api-extensions/src"], "@deepseek-ai/dsh-deque": ["./packages/util/deque/src"], - "@deepseek-ai/dsh-document-convert": ["./packages/document/document-convert/src"], - "@deepseek-ai/dsh-document-convert-libreoffice": ["./packages/document/document-convert-libreoffice/src"], "@deepseek-ai/dsh-file-reference": ["./packages/context/file-reference/src"], "@deepseek-ai/dsh-file-reference-local": ["./packages/context/file-reference-local/src"], "@deepseek-ai/dsh-fs": ["./packages/fs/fs/src"], @@ -367,6 +365,7 @@ "@deepseek-ai/dsh-mcp-resources": ["./packages/mcp/mcp-resources/src"], "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"], "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"], + "@deepseek-ai/dsh-office-to-pdf": ["./packages/document/office-to-pdf/src"], "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"], "@deepseek-ai/dsh-package-manifest": ["./packages/util/package-manifest/src"], "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 88c2000975..915d2d4667 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -160,8 +160,7 @@ { "path": "./packages/computer-use/computer-use" }, { "path": "./packages/experimental/computer-use-cua-driver-mcp" }, { "path": "./packages/experimental/computer-use-cua-driver-native" }, - { "path": "./packages/document/document-convert" }, - { "path": "./packages/document/document-convert-libreoffice" }, + { "path": "./packages/document/office-to-pdf" }, { "path": "./packages/client/ui-deliverables/tsconfig.host.json" }, { "path": "./vendor/cosmokit" }, { "path": "./vendor/schemastery" }, From 773d743bce81e49248e34406ea81baa146216ee7 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 18:39:38 +0800 Subject: [PATCH 6/9] docs(office-to-pdf): clarify conversion and engine package ownership --- .../2026-09-15-bounded-office-rendering.i18n.yaml | 4 ++-- .../architecture/2026-09-15-bounded-office-rendering.md | 6 +++--- .../architecture/2026-09-15-bounded-office-rendering.zh.md | 6 +++--- .../2026-09-15-platform-office-engines.i18n.yaml | 4 ++-- .../architecture/2026-09-15-platform-office-engines.md | 2 ++ .../architecture/2026-09-15-platform-office-engines.zh.md | 2 ++ apps/desktop/scripts/prepare-package-set.ts | 1 + packages/document/office-to-pdf/src/queue.ts | 2 +- packages/document/office-to-pdf/tests/output.spec.ts | 2 +- 9 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml index f4d8c83c91..323e9d7b68 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.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-15-bounded-office-rendering.md -2026-09-15-bounded-office-rendering.md: 242434361e665359ebfa6d934b5257fc0df12ed9 -2026-09-15-bounded-office-rendering.zh.md: b8268a5e9c421bb7b205136f66213acaa7aa8548 +2026-09-15-bounded-office-rendering.md: ae6ac3ec78d826cea69ff9adf9399a555206d022 +2026-09-15-bounded-office-rendering.zh.md: 5dd1c69df16e87819dece7bd00ebc367861177bf diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md index 242434361e..ae6ac3ec78 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md @@ -1,4 +1,4 @@ -# Agent Note: Bounded shared Office rendering +# Agent Note: Bounded shared Office conversion Status: implemented @@ -12,7 +12,7 @@ Office preview and explicit document inspection can request the same conversion. The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. -The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each renderer lifetime adds a generation so engine/font/configuration replacement invalidates reuse. +The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. @@ -34,4 +34,4 @@ Foreground preview and explicit QA requests precede background work. Disabling b The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. -Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and renderer replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. +Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md index b8268a5e9c..5dd1c69df1 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 有界的共享 Office 渲染 +# Agent Note: 有界的共享 Office 转换 Status: implemented @@ -12,7 +12,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 `office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 -[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个渲染器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 +[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 @@ -34,4 +34,4 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 -受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及渲染器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 +受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml index e10cadf677..7ba1bf8a3e 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.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-15-platform-office-engines.md -2026-09-15-platform-office-engines.md: a3d1365bf33006c31d53c54f1255f72c4663cb5d -2026-09-15-platform-office-engines.zh.md: dfffd1b9de33a2b138e0450f8a60d940fadcefd4 +2026-09-15-platform-office-engines.md: 4272054672c6163f22b910ab64b6472a9dbdfb4c +2026-09-15-platform-office-engines.zh.md: d0a317333049734415ab9c0c05a5624b63b27447 diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md index a3d1365bf3..4272054672 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md @@ -26,4 +26,6 @@ Python sidecar assembly copies only the selected engine and its dependency closu Distributions with a declared native target omit WASM assets. Other targets retain WASM resource and font requirements. The pinned kit declares macOS/Windows ARM64 and x64 native packages, so current Linux distributions select WASM; a kit release can add a native Linux target without changing Harness’s selection rule. This does not expand Harness’s supported release platforms. Harness sidecar, wheel, and runtime-resolution tests cover both declared native targets and WASM selection, including missing native packages. New package bytes require kit qualification and matching dependency integrity records before publication. +A new engine package identity also requires updates to `LIBREOFFICE_PACKAGES` in `scripts/gen-third-party-notices.ts`, any applicable `minimumReleaseAgeExclude` entry in `pnpm-workspace.yaml`, and the package list in the [kit ownership note](2026-09-14-independent-libreoffice-kit.md). The license allowlist remains explicit. + The [public Python release workflow](../../../../.github/workflows/python-release.yml) rejects any wheel at or above 100,000,000 bytes. Selecting one engine reduces payload size but does not establish that a runtime wheel meets this limit; npm engine publication and local conversion are separate from wheel upload eligibility. diff --git a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md index dfffd1b9de..d0a3173330 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md @@ -26,4 +26,6 @@ Python sidecar 组装仅复制所选引擎及其依赖闭包。wheel 打包和 声明了原生目标的分发物省去 WASM 资源,其余目标保留 WASM 的资源和字体要求。锁定的 kit 声明了 macOS/Windows ARM64 和 x64 原生包,因此当前 Linux 分发物选择 WASM;kit 发布版本可以新增 Linux 原生目标,无需修改 Harness 的选择规则。这不扩展 Harness 支持的发布平台。Harness 的 sidecar、wheel 和运行时解析测试覆盖已声明原生目标与 WASM 选择,包括原生包缺失。新包字节在发布前需要 kit 资格验证和匹配的依赖完整性记录。 +新增引擎包标识还需要更新 `scripts/gen-third-party-notices.ts` 中的 `LIBREOFFICE_PACKAGES`、`pnpm-workspace.yaml` 中适用的 `minimumReleaseAgeExclude` 条目,以及 [kit 归属记录](2026-09-14-independent-libreoffice-kit.zh.md)中的包列表。许可证允许列表仍使用明确的包标识。 + [公开 Python 发布工作流](../../../../.github/workflows/python-release.yml)拒绝任何大于等于 100,000,000 字节的 wheel。只选择一个引擎会减少载荷,但不能据此认定运行时 wheel 已满足此限制;npm 引擎发布、本地转换与 wheel 上传资格是不同的验证。 diff --git a/apps/desktop/scripts/prepare-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts index f22863e34c..7e7d62eea2 100644 --- a/apps/desktop/scripts/prepare-package-set.ts +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -52,6 +52,7 @@ function dependencyNames(manifest: Readonly>, section: s /** * Select workspace dependencies rooted at dsh and its private Host; npm resolves external packages. + * Reads the repository workspace manifest and package manifests to distinguish required local packages from npm-resolved externals. * @param available - Packed packages indexed by package name. * @returns Selected packages sorted by name. */ diff --git a/packages/document/office-to-pdf/src/queue.ts b/packages/document/office-to-pdf/src/queue.ts index 6270512e07..90d8b551ec 100644 --- a/packages/document/office-to-pdf/src/queue.ts +++ b/packages/document/office-to-pdf/src/queue.ts @@ -92,7 +92,7 @@ export class ConversionQueue { const abort = (): void => { this.release(reader) const reason: unknown = signal?.reason - reject(reason instanceof Error ? reason : new Error('Document rendering cancelled', { cause: reason })) + reject(reason instanceof Error ? reason : new Error('Office conversion cancelled', { cause: reason })) if (reader.job.readers.size === 0) this.cancel(reader.job) // Cancellation or demotion can unblock queued background work. this.drain() diff --git a/packages/document/office-to-pdf/tests/output.spec.ts b/packages/document/office-to-pdf/tests/output.spec.ts index ad897ef5f0..52ef31fb27 100644 --- a/packages/document/office-to-pdf/tests/output.spec.ts +++ b/packages/document/office-to-pdf/tests/output.spec.ts @@ -23,7 +23,7 @@ afterEach(() => { io.opened = undefined; vi.restoreAllMocks() }) const pdf = Buffer.from('%PDF-1.7\npreview\n%%EOF\n') async function output(): Promise<{ directory: string; path: string; handles: FileHandle[] }> { - const directory = await mkdtemp(join(tmpdir(), 'dsh-render-output-')) + const directory = await mkdtemp(join(tmpdir(), 'dsh-office-to-pdf-output-')) const handles: FileHandle[] = [] onTestFinished(async () => { try { await Promise.all(handles.map(file => file.close())) } From e30a391326bbfafcc916771aa2539d5a7a9e1234 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 16 Sep 2026 19:57:41 +0800 Subject: [PATCH 7/9] docs(office-to-pdf): link the bounded conversion decision --- ...-09-15-bounded-office-conversion.i18n.yaml | 6 +++ .../2026-09-15-bounded-office-conversion.md | 37 +++++++++++++++++++ ...2026-09-15-bounded-office-conversion.zh.md | 37 +++++++++++++++++++ .../document/office-to-pdf/README.i18n.yaml | 4 +- packages/document/office-to-pdf/README.md | 2 + packages/document/office-to-pdf/README.zh.md | 2 + 6 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md create mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml new file mode 100644 index 0000000000..5b2a534550 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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-15-bounded-office-conversion.md +2026-09-15-bounded-office-conversion.md: 6a86fc8d24185d8f4d327e0407f21d55dc860a53 +2026-09-15-bounded-office-conversion.zh.md: d267c9ccb2b388c87c3823a9c998074ff9f01df8 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md new file mode 100644 index 0000000000..6a86fc8d24 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -0,0 +1,37 @@ +# Agent Note: Bounded shared Office conversion + +Status: implemented + +English | [中文](2026-09-15-bounded-office-conversion.zh.md) + +## Problem + +Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion. + +## Decision + +The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. + +The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. + +A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. + +Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller. + +## Alternatives considered + +**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion. + +**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content. + +**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work. + +**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. + +**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. + +## Consequences + +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. + +Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md new file mode 100644 index 0000000000..d267c9ccb2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 有界的共享 Office 转换 + +Status: implemented + +[English](2026-09-15-bounded-office-conversion.md) | 中文 + +## 问题 + +Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。 + +## 决策 + +`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 + +[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 + +有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 + +前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。 + +## 考虑过的替代方案 + +**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。 + +**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。 + +**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。 + +**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 + +**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 + +## 后果 + +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 + +受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/packages/document/office-to-pdf/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml index b0019ba621..9d9273a2ec 100644 --- a/packages/document/office-to-pdf/README.i18n.yaml +++ b/packages/document/office-to-pdf/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/document/office-to-pdf/README.md -README.md: 75f26612e649a4722543505cca9956541f07007b -README.zh.md: d9ecfb7347d22952b6100324926163649389773d +README.md: babfcaaa4136d58c586ab5c60e4a3a112d64b636 +README.zh.md: eb57fc6f42f6617886e154a176f40683f1e4e561 diff --git a/packages/document/office-to-pdf/README.md b/packages/document/office-to-pdf/README.md index 75f26612e6..babfcaaa41 100644 --- a/packages/document/office-to-pdf/README.md +++ b/packages/document/office-to-pdf/README.md @@ -42,6 +42,8 @@ The provider depends on the independently published [`@deepseek-ai/libreoffice-k The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-office-to-pdf) owns the full font, archive, and image settings. `fontDirectories` accepts absolute directories; omission uses the kit platform defaults. Explicit `fontFallbacks` replaces the kit's default groups. Installed requested fonts retain precedence, and other system fonts remain eligible for uncovered glyphs. Native engines can select installed metric-compatible fonts before these preferences. +The [bounded conversion decision](../../../.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md) explains queue admission, cache limits, and shared cancellation. + The provider retains successful PDFs by converter generation, Office extension, and SHA-256 of the exact source bytes. A bounded source-version index avoids rereading known content after an authorized stat; content identity also shares conversion across different source paths. Least-recently-used PDFs leave at either retention limit, together with their aliases. Failures and oversized cache entries are not retained. Every result has independent PDF/font buffers. Ready alias hits consume no reader slot; active source locators are released when their last reader leaves. Reopening a source after its final reader cancels rereads its bytes before sharing by digest, even if another source kept the conversion alive or its PDF is ready. Admission bounds queued metadata, outstanding readers, active source-byte reservations, and conversions before invoking a source read. Unknown source sizes reserve `maxInputBytes`; known sizes reserve their stat size. Reads receive that capacity and may read one overflow sentinel byte. `maxSourceBytes` must cover `maxInputBytes`. The final reader allowance is reserved for foreground work. Setting `maxBackgroundConversions` to zero rejects background joins to queued and running work; completed alias hits remain available. Background jobs wait while any foreground job is queued, including when it awaits source capacity. Foreground joins promote queued prewarming; when its last foreground reader leaves, the queued job returns to background priority and eligible work can start immediately. Foreground admission can evict queued speculation. Background concurrency leaves a foreground slot when total concurrency exceeds one. A running prewarm keeps its background admission slot until settlement, even after promotion. The final reader cancels shared work. Removing a queued foreground blocker immediately admits other eligible work; active reservations remain held until actual read/conversion cleanup settles. diff --git a/packages/document/office-to-pdf/README.zh.md b/packages/document/office-to-pdf/README.zh.md index d9ecfb7347..eb57fc6f42 100644 --- a/packages/document/office-to-pdf/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -42,6 +42,8 @@ kind: "package-reference" [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-office-to-pdf)定义全部字体、归档和图像设置。`fontDirectories` 接受绝对目录;省略时使用 kit 的平台默认值。显式 `fontFallbacks` 替换 kit 的默认分组。已安装的请求字体仍优先使用,缺失字形仍可由其他系统字体提供。原生引擎可能在应用这些优先规则前选中已安装的度量兼容字体。 +[有界转换决策](../../../.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md)说明队列准入、缓存限额与共享取消的设计依据。 + 提供方按转换 generation、Office 扩展名和精确源字节的 SHA-256 保留成功 PDF。有界的源版本索引在授权 stat 后避免重读已知内容;内容标识也会在不同源路径之间共享转换。达到任一保留上限时,最近最少使用的 PDF 及其别名一同移除。不保留失败或超过缓存上限的结果。每个结果具有独立的 PDF 与字体缓冲区。已就绪别名命中不占用读取方名额;同一源的最后一个读取方离开时,立即释放其在途定位信息。源的最后一个读取方取消后,再次打开该源会重新读取字节,再按内容摘要共享转换,即使其他源仍保持该转换运行或其 PDF 已就绪。 准入在调用源读取前限制排队元数据、未完成读取方、活动源字节预留和转换。未知源大小预留 `maxInputBytes`;已知大小预留 stat 字节数。读取收到该容量,最多额外读取一个超限哨兵字节。`maxSourceBytes` 必须覆盖 `maxInputBytes`。最后一个读取方额度预留给前台。`maxBackgroundConversions` 设为零时,拒绝后台读取方加入排队或运行中的工作;仍可命中已完成的别名缓存。只要仍有前台任务排队,后台任务就继续等待,包括前台正在等待源容量的情况。前台加入会提升排队预热;最后一个前台读取方离开后,排队任务恢复后台优先级,符合条件的工作可立即开始。前台准入也可移除排队推测工作。总并发大于一时,后台并发为前台保留一个槽位。正在运行的预热即使被提权,也保留后台准入槽位直至结束。最后一个读取方取消共享工作。移除排队的前台阻塞任务后,其他符合条件的工作立即准入;实际读取、转换和清理完成前仍保留活动预留容量。 From b911efef39c02ee3d4e1c95ee996e527fa96b33e Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 08:44:54 +0800 Subject: [PATCH 8/9] refactor(workspace-files): reuse bounded reads and return raw Host bytes --- ...-09-15-bounded-office-conversion.i18n.yaml | 4 +- .../2026-09-15-bounded-office-conversion.md | 4 +- ...2026-09-15-bounded-office-conversion.zh.md | 4 +- ...6-09-15-bounded-office-rendering.i18n.yaml | 6 --- .../2026-09-15-bounded-office-rendering.md | 37 ---------------- .../2026-09-15-bounded-office-rendering.zh.md | 37 ---------------- docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 4 +- docs/subsystems/workspace.zh.md | 4 +- packages/api/workspace-files/README.i18n.yaml | 4 +- packages/api/workspace-files/README.md | 2 +- packages/api/workspace-files/README.zh.md | 2 +- packages/api/workspace-files/src/index.ts | 22 +++++----- .../workspace-files/tests/read-all.spec.ts | 44 ++++++++++++++----- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- pnpm-lock.yaml | 10 ++--- 16 files changed, 70 insertions(+), 122 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md delete mode 100644 .agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml index 5b2a534550..3538f2ec40 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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-15-bounded-office-conversion.md -2026-09-15-bounded-office-conversion.md: 6a86fc8d24185d8f4d327e0407f21d55dc860a53 -2026-09-15-bounded-office-conversion.zh.md: d267c9ccb2b388c87c3823a9c998074ff9f01df8 +2026-09-15-bounded-office-conversion.md: af24cee4bc73c40135a15f70cff9201e59280da7 +2026-09-15-bounded-office-conversion.zh.md: 29411e117653755e2fd7ddce8e2fe516ffdf226f diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md index 6a86fc8d24..af24cee4bc 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -30,8 +30,10 @@ Foreground preview and explicit QA requests precede background work. Disabling b **Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. +Host workspace-file reads return raw bytes within the reserved capacity and delegate complete bounded reading to `fs.readBytes`. Base64 encoding belongs to Remote responses, so Host conversion does not allocate an encoded source string or a decoded copy. + ## Consequences -The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. +The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md index d267c9ccb2..29411e1176 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -30,8 +30,10 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 **为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 +Host 工作区文件读取在预留容量内返回原始字节,并将完整有界读取交给 `fs.readBytes`。base64 编码由 Remote 响应负责,因此 Host 转换无需分配编码后的源字符串或解码副本。 + ## 后果 -缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 +缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml deleted file mode 100644 index 323e9d7b68..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-15-bounded-office-rendering.md -2026-09-15-bounded-office-rendering.md: ae6ac3ec78d826cea69ff9adf9399a555206d022 -2026-09-15-bounded-office-rendering.zh.md: 5dd1c69df16e87819dece7bd00ebc367861177bf diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md deleted file mode 100644 index ae6ac3ec78..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: Bounded shared Office conversion - -Status: implemented - -English | [中文](2026-09-15-bounded-office-rendering.zh.md) - -## Problem - -Office preview and explicit document inspection can request the same conversion. A completed-result cache alone leaves source reads, queued payloads, and concurrent readers unbounded. Speculation can also occupy capacity needed by a user, and canceling one consumer must not destroy another consumer's conversion. - -## Decision - -The `office-to-pdf` service returns complete PDF bytes. Page rasterization and user presentation remain separate consumers, so conversion naming does not imply image rendering or preview UI. - -The [Host provider](../../../../packages/document/office-to-pdf/README.md) owns a shared conversion queue and transient content cache. Authorized source metadata enters admission before source bytes are loaded. The source callback receives reserved byte capacity and returns its read version; changed sources fail without publishing aliases. Exact source bytes and Office extension determine the digest. Each converter lifetime adds a generation so engine/font/configuration replacement invalidates reuse. - -A bounded source-version index avoids repeated reads after authorization; the digest remains the identity for sharing conversion across distinct paths. Ready PDFs use an entry/byte-bounded LRU. Queued jobs contain metadata and deferred callbacks. Reader, queue, source-byte, and conversion limits also apply before work completes. Each active source locator belongs to live readers, so cancellation cannot grow retained source metadata independently of reader admission. Unknown source sizes reserve the input cap; cancellation retains active capacity until actual read/conversion cleanup settles. - -Foreground preview and explicit QA requests precede background work. Disabling background conversions rejects speculative readers before they can join queued or running jobs, while completed alias hits remain available. Removing a queued foreground blocker immediately admits eligible background work. Queued priority follows live readers: a foreground join promotes a prewarm, and the final foreground cancellation demotes it and admits eligible work. Full queues evict queued speculation for foreground admission. Background concurrency reserves a foreground slot when total concurrency permits it. A speculative admission remains occupied through settlement, so promotion cannot admit additional speculation into capacity reserved for user work. Shared readers cancel independently, including readers joined after content hashing. The cache owns private output bytes and returns a copy to each caller. - -## Alternatives considered - -**Cache only completed PDFs.** This cannot bound pending source buffers, engine work, or response fanout, and separate consumers still duplicate conversion. - -**Use source metadata as the final cache identity.** Metadata is useful before reading, but distinct authorized paths can contain identical bytes. Content hashing provides cross-path reuse without treating a path as document content. - -**Cancel the entire conversion when one reader leaves.** An open preview can share work with speculation or explicit QA. Only the final reader owns cancellation of shared work. - -**Build a second prewarm or QA converter.** Independent queues duplicate resource ownership and cannot prioritize shared foreground work. - -**Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. - -## Consequences - -The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. - -Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md deleted file mode 100644 index 5dd1c69df1..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-rendering.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: 有界的共享 Office 转换 - -Status: implemented - -[English](2026-09-15-bounded-office-rendering.md) | 中文 - -## 问题 - -Office 预览和显式文档检查可能请求相同转换。仅缓存已完成结果无法限制源读取、排队载荷和并发读取方。推测工作也可能占用用户需要的容量,而取消一个消费者不能破坏另一个消费者的转换。 - -## 决策 - -`office-to-pdf` 服务返回完整 PDF 字节。页面栅格化和用户展示由独立消费方负责,因此转换命名不隐含图片渲染或预览 UI。 - -[宿主提供方](../../../../packages/document/office-to-pdf/README.zh.md)拥有共享转换队列和临时内容缓存。已授权的源文件元数据在加载字节之前进入准入流程。源回调接收预留的字节容量并返回读取版本;源文件变化会导致失败,不发布别名。确切的源字节和 Office 扩展名决定摘要。每个转换器生命周期附加代次,因此引擎、字体或配置替换会使复用失效。 - -有界的源版本索引在授权后避免重复读取;摘要仍是不同路径间共享转换的身份。已就绪 PDF 使用按条目与字节限制的 LRU。排队任务包含元数据和延迟回调。读取方、队列、源字节与转换限制在工作完成前也适用。每个在途源定位信息归属于活跃读取方,因此取消操作不能让保留的源元数据脱离读取方准入限制增长。未知源大小预留输入上限;取消后仍保留活动容量,直至实际读取、转换与清理结束。 - -前台预览和显式 QA 请求优先于后台工作。禁用后台转换时,推测读取方在加入排队或运行任务前即被拒绝,但仍可命中已完成的别名缓存。移除排队的前台阻塞任务后,符合条件的后台工作立即准入。排队优先级取决于活跃读取方:前台加入会提升预热,最后一个前台读取方取消后则恢复后台优先级,并准入符合条件的工作。队列满时为前台准入移除排队推测工作。总并发允许时,后台并发为前台预留一个槽位。推测工作的准入名额保留到工作结束,因此提权不会把为用户预留的容量用于更多推测工作。共享读取方独立取消,包括内容哈希后加入的读取方。缓存拥有私有输出字节,并为各调用方返回副本。 - -## 考虑过的替代方案 - -**仅缓存已完成 PDF。** 无法限制待完成源缓冲区、引擎工作和响应扇出,不同消费者仍会重复转换。 - -**以源元数据作为最终缓存身份。** 元数据在读取前有用,但不同的已授权路径可能包含相同字节。内容哈希提供跨路径复用,而不把路径当作文档内容。 - -**一个读取方离开就取消整个转换。** 打开的预览可能与推测工作或显式 QA 共享工作。只有最后一个读取方拥有共享工作取消权。 - -**另建预热或 QA 转换器。** 独立队列重复拥有资源,无法优先调度共享前台工作。 - -**为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 - -## 后果 - -缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 - -受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index d933a9055e..f69e27abee 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/subsystems/workspace.md -workspace.md: 50db26be228bf39880891c3c4ca7d47c097b9d3e -workspace.zh.md: fd4110041fe1b22e580b17835e4a896b96d062c8 +workspace.md: 9633bd74b6997554e0d25824746117b4760fac29 +workspace.zh.md: fd749cec14383ea746efb096437f841f57291962 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 50db26be22..9633bd74b6 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi * @param path - absolute or workspace-relative file path. * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. * @param signal - caller cancellation. - * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. */ -async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise /** * Read a complete file relative to another file's directory, including outside the workspace. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index fd4110041f..fd749cec14 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -392,9 +392,9 @@ Host Remote file reads and workspace directory observations over the composed fi * @param path - absolute or workspace-relative file path. * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. * @param signal - caller cancellation. - * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. */ -async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise +async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise /** * Read a complete file relative to another file's directory, including outside the workspace. diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 7c954c743a..1d33d436a8 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/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/workspace-files/README.md -README.md: 9da773d65220e355c5dc41e372bc2bb0ca868d91 -README.zh.md: 56276e5da266b3ba81bfa83358df94d19e103ee4 +README.md: a3ccbc24ca37b5b933c34390773d10a223e99d36 +README.zh.md: a7e59c07d5d99216a2e8f6659ed3dc3201846a62 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index 9da773d652..a3ccbc24ca 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -82,7 +82,7 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. -Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. The smaller of that reservation and `maxFileBytes` applies; the read allocates at most one additional overflow sentinel byte. +Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. It returns raw `Uint8Array` data with file metadata. The filesystem enforces the smaller of that reservation and `maxFileBytes`; `readAll()` encodes the result as base64 for Remote callers. ----- diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 56276e5da2..a7e59c07d5 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -82,7 +82,7 @@ kind: "package-reference" 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 -Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。采用预留值与 `maxFileBytes` 的较小值;读取最多额外分配一个超限哨兵字节。 +Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。它返回原始 `Uint8Array` 数据及文件元数据。文件系统执行预留值与 `maxFileBytes` 中较小的上限;`readAll()` 为 Remote 调用方将结果编码为 base64。 ----- diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index e099056cd4..a22b16ae54 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -276,7 +276,8 @@ export class WorkspaceFiles extends TypertRemoteService { */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { - return this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) + const { data, ...stat } = await this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) + return { ...stat, offset: 0, data: Buffer.from(data).toString('base64'), eof: true } } /** @@ -285,21 +286,20 @@ export class WorkspaceFiles extends TypertRemoteService { * @param path - absolute or workspace-relative file path. * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. * @param signal - caller cancellation. - * @returns complete base64 bytes; reads at most the effective limit plus one overflow sentinel. + * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. */ async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, - ): Promise { + ): Promise { const { target, info } = await this.locateFile(workspaceFileScope, path, signal) const limit = Math.min(this.config.maxFileBytes, maxBytes) - if (info.size !== undefined && info.size > limit) { - throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) - } - const data = await this.ctx.fs.readByteRange(target, { offset: 0, length: limit + 1 }, signal) - if (data.length > limit) { - throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }) - } - return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } + const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { + if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') { + throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }, { cause }) + } + throw cause + }) + return { ...this.statOf(target, info), data } } /** diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index 8bf2470241..f610936d78 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -24,13 +24,11 @@ describe('workspaceFiles.readAll', () => { expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) }) - it.each(['workspace', 'outside'] as const)('rejects a known oversized %s file before reading bytes', async (location) => { + it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => { const path = join(harness[location], 'large') await writeFile(path, 'abcde') - const read = vi.spyOn(harness.ctx.fs, 'readByteRange') expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal()))) .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) - expect(read).not.toHaveBeenCalled() }) it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { @@ -51,15 +49,41 @@ describe('workspaceFiles.readAll', () => { }) }) -it('applies the smaller Host reservation before reading a complete file', async () => { +it.each([[8, 3], [3, 8]])('applies the smaller cap from deployment %s and Host reservation %s', async (maxFileBytes, reservation) => { await writeFile(join(harness.workspace, 'reserved'), '1234') - const read = vi.spyOn(harness.ctx.fs, 'readByteRange') - const files = harness.endpoint({ maxFileBytes: 8 }) - expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', 3, signal()))) + const read = vi.spyOn(harness.ctx.fs, 'readBytes') + const files = harness.endpoint({ maxFileBytes }) + const caller = signal() + expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', reservation, caller))) .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } }) - expect(read).not.toHaveBeenCalled() - expect((await files.readAllBounded(harness.scope, 'reserved', 4, signal())).bytes).toBe(4) - expect(read.mock.calls[0]![1]).toEqual({ offset: 0, length: 5 }) + expect(read).toHaveBeenCalledExactlyOnceWith(expect.anything(), caller, 3) +}) + +it('returns the filesystem byte array directly to Host consumers at the exact cap', async () => { + await writeFile(join(harness.workspace, 'reserved'), '1234') + const read = vi.spyOn(harness.ctx.fs, 'readBytes') + const result = await harness.endpoint({ maxFileBytes: 8 }).readAllBounded(harness.scope, 'reserved', 4, signal()) + expect(result.data).toBe(await read.mock.results[0]!.value) + expect(result.data).toEqual(Buffer.from('1234')) + expect(result.bytes).toBe(4) + expect(result).not.toHaveProperty('offset') + expect(result).not.toHaveProperty('eof') +}) + +it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( + 'preserves non-size filesystem failures: %s', async (failure) => { + await writeFile(join(harness.workspace, 'file'), '1234') + vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) + await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())).rejects.toBe(failure) + }, +) + +it('maps a size refusal by code without requiring a shared error class', async () => { + await writeFile(join(harness.workspace, 'file'), '1234') + const failure = { code: 'FS_TOO_LARGE' } + vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) + await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())) + .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) }) describe('workspaceFiles.readRelated', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index fb2e9c3074..955440c763 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3247,10 +3247,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', }, { - signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', + signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.', parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'complete base64 bytes; reads at most the effective limit plus one overflow sentinel.', + returns: 'complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.', }, { signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c221b9294..23120ed0df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8106,7 +8106,7 @@ importers: version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.85.1 - version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) + version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -20561,14 +20561,14 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2 + http-proxy-agent: 7.0.2(supports-color@9.4.0) https-proxy-agent: 7.0.6 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 @@ -23196,7 +23196,7 @@ snapshots: cross-spawn: 7.0.6 debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 - http-proxy-agent: 7.0.2 + http-proxy-agent: 7.0.2(supports-color@9.4.0) https-proxy-agent: 7.0.6 js-yaml: 4.3.1 sanitize-filename: 1.6.4 @@ -24511,7 +24511,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@9.4.0): dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@9.4.0) From 12302ac6c3b15700a3dc9e83d9b51a517ff23edf Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 17 Sep 2026 10:44:04 +0800 Subject: [PATCH 9/9] refactor(workspace-files): keep bounded reads within readAll --- ...-09-15-bounded-office-conversion.i18n.yaml | 4 +- .../2026-09-15-bounded-office-conversion.md | 2 - ...2026-09-15-bounded-office-conversion.zh.md | 2 - docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 10 ----- docs/subsystems/workspace.zh.md | 10 ----- packages/api/workspace-files/README.i18n.yaml | 4 +- packages/api/workspace-files/README.md | 4 +- packages/api/workspace-files/README.zh.md | 4 +- packages/api/workspace-files/src/index.ts | 19 +------- .../workspace-files/tests/read-all.spec.ts | 45 +++++-------------- .../extensions/tool-cordis/src/api-catalog.ts | 6 --- 12 files changed, 24 insertions(+), 90 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml index 3538f2ec40..6cfc0f2673 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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-15-bounded-office-conversion.md -2026-09-15-bounded-office-conversion.md: af24cee4bc73c40135a15f70cff9201e59280da7 -2026-09-15-bounded-office-conversion.zh.md: 29411e117653755e2fd7ddce8e2fe516ffdf226f +2026-09-15-bounded-office-conversion.md: 75e61ce0c49dafac68d4cf3a653d13cbee096e4c +2026-09-15-bounded-office-conversion.zh.md: a2accf02ec058fa90b83e36bc761a2b7637eaef9 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md index af24cee4bc..75e61ce0c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -30,8 +30,6 @@ Foreground preview and explicit QA requests precede background work. Disabling b **Separate service-definition and provider packages for the sole LibreOffice implementation.** They evolve together and have no independent alternative implementation. One `office-to-pdf` package supplies the mountable service without duplicated package, dependency, and release configuration. Native and WASM engine selection remains inside the kit; a second independent implementation can justify extracting an interface from actual consumer needs. -Host workspace-file reads return raw bytes within the reserved capacity and delegate complete bounded reading to `fs.readBytes`. Base64 encoding belongs to Remote responses, so Host conversion does not allocate an encoded source string or a decoded copy. - ## Consequences The cache is transient and cannot bypass source authorization. Oversized PDFs can be returned without retention, and failed or canceled conversions are retried on a later explicit request. Source reservations measure binary bytes; Remote base64 expansion, engine RSS, caller-retained output, and PDF.js page memory remain outside those limits. With one configured conversion slot, foreground work waits for an already-running background conversion to finish. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md index 29411e1176..a2accf02ec 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -30,8 +30,6 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 **为唯一的 LibreOffice 实现拆分服务定义和提供方包。** 两者共同演化,没有独立的替代实现。单个 `office-to-pdf` 包直接提供可挂载服务,避免重复维护包、依赖和发布配置。原生与 WASM 引擎选择仍由 kit 负责;若出现独立的第二种实现,再根据实际消费者提取接口。 -Host 工作区文件读取在预留容量内返回原始字节,并将完整有界读取交给 `fs.readBytes`。base64 编码由 Remote 响应负责,因此 Host 转换无需分配编码后的源字符串或解码副本。 - ## 后果 缓存为临时数据,不能绕过源授权。超出缓存上限的 PDF 可返回而不保留;失败或取消的转换在后续显式请求时重试。源预留按二进制字节计量;Remote base64 膨胀、引擎 RSS、调用方保留的输出和 PDF.js 页面内存不计入这些限制。仅配置一个转换槽位时,前台工作等待已运行的后台转换结束。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index f69e27abee..b362e8d475 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/subsystems/workspace.md -workspace.md: 9633bd74b6997554e0d25824746117b4760fac29 -workspace.zh.md: fd749cec14383ea746efb096437f841f57291962 +workspace.md: 80ea3157dbd904e168e2809ce8aa0b8641c1b2d9 +workspace.zh.md: 727c612e58cdbe54b0ba0e6292803d4543e5a32e diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 9633bd74b6..80ea3157db 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -386,16 +386,6 @@ Host Remote file reads and workspace directory observations over the composed fi */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -/** - * Read a complete authorized file within a Host consumer's reserved byte capacity. - * @param workspaceFileScope - Session authorization and execution scope. - * @param path - absolute or workspace-relative file path. - * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. - * @param signal - caller cancellation. - * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. - */ -async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise - /** * Read a complete file relative to another file's directory, including outside the workspace. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index fd749cec14..727c612e58 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -386,16 +386,6 @@ Host Remote file reads and workspace directory observations over the composed fi */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -/** - * Read a complete authorized file within a Host consumer's reserved byte capacity. - * @param workspaceFileScope - Session authorization and execution scope. - * @param path - absolute or workspace-relative file path. - * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. - * @param signal - caller cancellation. - * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. - */ -async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise - /** * Read a complete file relative to another file's directory, including outside the workspace. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 1d33d436a8..4bbf6a7a48 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/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/workspace-files/README.md -README.md: a3ccbc24ca37b5b933c34390773d10a223e99d36 -README.zh.md: a7e59c07d5d99216a2e8f6659ed3dc3201846a62 +README.md: f7e40138d8b790f993e3cc8a15d4216ca966cabb +README.zh.md: 04548bed7ffc1698e9a05ffbc93157ea07d6f932 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index a3ccbc24ca..f7e40138d8 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -82,8 +82,6 @@ The provider waits for the Host's `ready` frame before its first `stat`, queues One supervised `changes` stream serves every followed file in a Session. Followers match absolute paths with backslashes normalized to slashes. Carrier loss reconnects through the Gateway supervisor; a Host-ended or terminally failed feed ends its followers and leaves their last metadata readable until reopened. The last follower leaving disposes the stream, a successor waits for that disposal, and plugin teardown awaits all pending closes. The provider declares `ResourceProtocolMap.file`; the text preview declares its Sidebar line-navigation parameters. -Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after reserving input capacity. It returns raw `Uint8Array` data with file metadata. The filesystem enforces the smaller of that reservation and `maxFileBytes`; `readAll()` encodes the result as base64 for Remote callers. - ----- @@ -96,6 +94,8 @@ Host consumers can call `readAllBounded(scope, path, maxBytes, signal)` after re Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and change observation. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. +Complete-file reads delegate size enforcement to `fs.readBytes` and encode the returned bytes as base64 for Remote responses. + ### Source map | File | Role | diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index a7e59c07d5..04548bed7f 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -82,8 +82,6 @@ kind: "package-reference" 每个 Session 的所有被跟随文件共用一条受监督的 `changes` 流。跟随者按反斜杠归一为斜杠的绝对路径匹配。载体掉线由 Gateway 监督器重连;Host 结束或终态失败的流会结束其跟随者,最后的元数据仍可读取,直到重新打开。最后一个跟随者离开时释放流,后继流等待该释放完成,插件拆除等待所有在途关闭。提供者声明 `ResourceProtocolMap.file`;文本预览声明其 Sidebar 行号导航参数。 -Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, maxBytes, signal)`。它返回原始 `Uint8Array` 数据及文件元数据。文件系统执行预留值与 `maxFileBytes` 中较小的上限;`readAll()` 为 Remote 调用方将结果编码为 base64。 - ----- @@ -96,6 +94,8 @@ Host 消费者可在预留输入容量后调用 `readAllBounded(scope, path, max 经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与变更观察。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 +完整文件读取将大小上限检查交给 `fs.readBytes`,并将返回的字节编码为 base64,供 Remote 响应使用。 + ### 源码地图 | 文件 | 职责 | diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index a22b16ae54..64e91c7fbf 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -276,30 +276,15 @@ export class WorkspaceFiles extends TypertRemoteService { */ @Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { - const { data, ...stat } = await this.readAllBounded(workspaceFileScope, path, this.config.maxFileBytes, signal) - return { ...stat, offset: 0, data: Buffer.from(data).toString('base64'), eof: true } - } - - /** - * Read a complete authorized file within a Host consumer's reserved byte capacity. - * @param workspaceFileScope - Session authorization and execution scope. - * @param path - absolute or workspace-relative file path. - * @param maxBytes - positive reserved capacity; the configured full-file cap still applies. - * @param signal - caller cancellation. - * @returns complete raw bytes and metadata from before the read; the filesystem enforces the effective limit. - */ - async readAllBounded( - workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, - ): Promise { const { target, info } = await this.locateFile(workspaceFileScope, path, signal) - const limit = Math.min(this.config.maxFileBytes, maxBytes) + const limit = this.config.maxFileBytes const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') { throw new RemoteError('workspace-file/too-large', `"${path}" exceeds the ${limit} byte full-file cap`, { path, limit }, { cause }) } throw cause }) - return { ...this.statOf(target, info), data } + return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } } /** diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index f610936d78..21d75bd849 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -47,43 +47,22 @@ describe('workspaceFiles.readAll', () => { const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal()) expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside') }) -}) -it.each([[8, 3], [3, 8]])('applies the smaller cap from deployment %s and Host reservation %s', async (maxFileBytes, reservation) => { - await writeFile(join(harness.workspace, 'reserved'), '1234') - const read = vi.spyOn(harness.ctx.fs, 'readBytes') - const files = harness.endpoint({ maxFileBytes }) - const caller = signal() - expect(await failureOf(files.readAllBounded(harness.scope, 'reserved', reservation, caller))) - .toEqual({ code: 'workspace-file/too-large', details: { path: 'reserved', limit: 3 } }) - expect(read).toHaveBeenCalledExactlyOnceWith(expect.anything(), caller, 3) -}) + it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( + 'preserves non-size filesystem failures: %s', async (failure) => { + await writeFile(join(harness.workspace, 'file'), '1234') + vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) + await expect(harness.endpoint().readAll(harness.scope, 'file', signal())).rejects.toBe(failure) + }, + ) -it('returns the filesystem byte array directly to Host consumers at the exact cap', async () => { - await writeFile(join(harness.workspace, 'reserved'), '1234') - const read = vi.spyOn(harness.ctx.fs, 'readBytes') - const result = await harness.endpoint({ maxFileBytes: 8 }).readAllBounded(harness.scope, 'reserved', 4, signal()) - expect(result.data).toBe(await read.mock.results[0]!.value) - expect(result.data).toEqual(Buffer.from('1234')) - expect(result.bytes).toBe(4) - expect(result).not.toHaveProperty('offset') - expect(result).not.toHaveProperty('eof') -}) - -it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( - 'preserves non-size filesystem failures: %s', async (failure) => { + it('maps a size refusal by code without requiring a shared error class', async () => { await writeFile(join(harness.workspace, 'file'), '1234') + const failure = { code: 'FS_TOO_LARGE' } vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())).rejects.toBe(failure) - }, -) - -it('maps a size refusal by code without requiring a shared error class', async () => { - await writeFile(join(harness.workspace, 'file'), '1234') - const failure = { code: 'FS_TOO_LARGE' } - vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint().readAllBounded(harness.scope, 'file', 4, signal())) - .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) + await expect(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'file', signal())) + .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) + }) }) describe('workspaceFiles.readRelated', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 955440c763..eaf21fa553 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3246,12 +3246,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'one complete base64 window with offset zero and eof true; oversized files fail with too-large.', }, - { - signature: 'async readAllBounded( workspaceFileScope: WorkspaceFileScope, path: string, maxBytes: number, signal: AbortSignal, ): Promise', - description: 'Read a complete authorized file within a Host consumer\'s reserved byte capacity.', - parameters: [{ name: 'workspaceFileScope', description: 'Session authorization and execution scope.' }, { name: 'path', description: 'absolute or workspace-relative file path.' }, { name: 'maxBytes', description: 'positive reserved capacity; the configured full-file cap still applies.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'complete raw bytes and metadata from before the read; the filesystem enforces the effective limit.', - }, { signature: '@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise', description: 'Read a complete file relative to another file\'s directory, including outside the workspace.',