From 8e4d3bab9fd0bb1b6b3fa4bc9a2174953cf9a1a8 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 8 Sep 2026 16:33:58 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=89=93?= =?UTF-8?q?=E5=8C=85=E6=96=B9=E5=BC=8F=E3=80=81=E5=90=AF=E5=8A=A8=E9=80=9F?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 60 +- ...ectron-desktop-packaging-and-updates.zh.md | 58 +- ...led-runtime-and-external-plugins.i18n.yaml | 6 + ...op-bundled-runtime-and-external-plugins.md | 51 ++ ...bundled-runtime-and-external-plugins.zh.md | 51 ++ ...ktop-uninstall-preserve-dsh-home.i18n.yaml | 6 + ...-08-desktop-uninstall-preserve-dsh-home.md | 95 ++++ ...-desktop-uninstall-preserve-dsh-home.zh.md | 95 ++++ apps/desktop-host/src/index.ts | 34 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 58 +- apps/desktop/README.zh.md | 58 +- apps/desktop/electron-builder.config.d.mts | 4 +- apps/desktop/electron-builder.config.mjs | 17 +- apps/desktop/package.json | 5 +- apps/desktop/renderer/plugin-manager.html | 6 + apps/desktop/renderer/plugin-manager.js | 19 +- .../desktop/scripts/desktop-build-paths.d.mts | 4 +- apps/desktop/scripts/desktop-build-paths.mjs | 6 +- apps/desktop/scripts/macos-runtime.ts | 43 ++ apps/desktop/scripts/macos-seed-store.ts | 413 -------------- apps/desktop/scripts/package-target.ts | 4 +- apps/desktop/scripts/prepare-dsh.ts | 150 +++++ apps/desktop/scripts/prepare-seed.ts | 200 ------- apps/desktop/scripts/smoke-runtime.ts | 58 ++ .../scripts/verify-macos-signature.d.mts | 12 +- .../scripts/verify-macos-signature.mjs | 22 +- apps/desktop/src/core-package-set.ts | 2 +- apps/desktop/src/host-process.ts | 13 +- apps/desktop/src/ipc.ts | 10 + apps/desktop/src/locale.ts | 14 + apps/desktop/src/main.ts | 65 ++- apps/desktop/src/preload.ts | 6 + apps/desktop/src/profile-packages.ts | 254 +++++++++ apps/desktop/src/project-manager.ts | 348 +++++------- apps/desktop/src/release.ts | 4 +- apps/desktop/src/runtime-tree.ts | 180 ++++++ apps/desktop/src/seed-store.ts | 271 --------- apps/desktop/src/update-coordinator.ts | 2 +- .../desktop/tests/desktop-build-paths.spec.ts | 6 +- apps/desktop/tests/host-process.spec.ts | 35 +- apps/desktop/tests/macos-runtime.spec.ts | 48 ++ apps/desktop/tests/macos-seed-store.spec.ts | 245 -------- apps/desktop/tests/macos-signature.spec.ts | 49 +- apps/desktop/tests/package-target.spec.ts | 2 +- apps/desktop/tests/plugin-manager.spec.ts | 33 ++ apps/desktop/tests/plugin-pnpm.spec.ts | 72 +++ apps/desktop/tests/profile-packages.spec.ts | 81 +++ apps/desktop/tests/project-manager.spec.ts | 537 ++++++------------ apps/desktop/tests/runtime-fixture.ts | 42 ++ apps/desktop/tests/runtime-tree.spec.ts | 46 ++ apps/desktop/tests/seed-store.spec.ts | 167 ------ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- pnpm-lock.yaml | 89 +-- 57 files changed, 2013 insertions(+), 2163 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md create mode 100644 .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md create mode 100644 .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md create mode 100644 apps/desktop/scripts/macos-runtime.ts delete mode 100644 apps/desktop/scripts/macos-seed-store.ts create mode 100644 apps/desktop/scripts/prepare-dsh.ts delete mode 100644 apps/desktop/scripts/prepare-seed.ts create mode 100644 apps/desktop/scripts/smoke-runtime.ts create mode 100644 apps/desktop/src/profile-packages.ts create mode 100644 apps/desktop/src/runtime-tree.ts delete mode 100644 apps/desktop/src/seed-store.ts create mode 100644 apps/desktop/tests/macos-runtime.spec.ts delete mode 100644 apps/desktop/tests/macos-seed-store.spec.ts create mode 100644 apps/desktop/tests/plugin-manager.spec.ts create mode 100644 apps/desktop/tests/plugin-pnpm.spec.ts create mode 100644 apps/desktop/tests/profile-packages.spec.ts create mode 100644 apps/desktop/tests/runtime-fixture.ts create mode 100644 apps/desktop/tests/runtime-tree.spec.ts delete mode 100644 apps/desktop/tests/seed-store.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 8aa7824f1f..e0081b64f5 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: 6d22777c8913911dbb1d89c09de9e891636873c8 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c +2026-08-25-electron-desktop-packaging-and-updates.md: fea2c419d73e4eff66de08483733cbc336b6f9af +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 88e2875cd35909c0111926377df83c9fe21841dc diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 6d22777c89..fea2c419d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -10,13 +10,13 @@ DeepSeek Harness needs an Electron desktop application that reuses the Web UI, w The desktop application and an npm-installed dsh share the `.dsh` data root, but they may have different dsh and plugin versions. They must share supported product data without sharing executable packages, lockfiles, `node_modules`, plugin activation, or package-manager configuration. -The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its pnpm-installed dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. +The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its bundled dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. ## Decision Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. Electron starts the private Desktop Host package as an isolated child process; that package composes the installed dsh backend and matching client graph. Fetch metadata and bounded raw request and response chunks travel over two versioned framed byte pipes, Node IPC is reserved for readiness, fatal failure, and shutdown, and Electron serves validated assets through `dsh-app://`; it opens no listening port. Each frame carries a fixed marker, type, monotonic stream id, payload length, and validated payload. Serialized writers honor pipe drain, readers pause globally when a request or response stream applies backpressure, cancellation closes the matching stream, and late response frames for a retired stream stay inert. The Connection plugin provides its carrier-neutral RPC and Fetch registries without requiring `webServer`, while Client Modules provides the exact advertised combo-bundle responses to the shell-owned carrier; Web compositions attach their optional HTTP routes for both. The renderer keeps the same Fetch, RPC, and Remote-stream formats, while the child carrier avoids Base64 expansion and V8 serialization compatibility between Electron and the bundled upstream Node.js. Electron closes its request-pipe writer after sending shutdown, releasing an in-flight Windows pipe read before it waits for child exit. This follows the Electron reservation in the [GUI layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). -Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deepseek-ai/dsh` dependency supplies the backend and matching Web UI, while the matching private `@deepseek-ai/dsh-desktop-host` dependency supplies only the Electron child-process entry and composition overlay. The dsh release, private Host, and their first-party dependency closures use local npm tarballs packed from the same source build; the profile manifest lists every core package as a local `file:` dependency, and `pnpm-workspace.yaml` repeats the mapping as overrides. The Host remains outside the public CLI package and is never published to npm. Desktop plugins are additional registry npm dependencies and ordered `dsh.profile.bundles` entries in the same profile, and resolve from its one `node_modules`. +Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resource storage, external plugin dependencies, shared package links, and profile reconciliation. The private Desktop Host remains outside the public CLI package and is never published to npm. One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged. @@ -28,7 +28,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p |---|---| | Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback | | Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state | -| Desktop profile | One dependency graph, ordered bundle list, and `node_modules` for the desktop dsh package and desktop plugins | +| Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision | | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication | | Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior | | Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions | @@ -55,15 +55,13 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb package.json pnpm-lock.yaml pnpm-workspace.yaml - desktop-release.json - desktop-packages.json - desktop-packages/ + desktop-runtime-state.json node_modules/ sessions/ storages/ ``` -`.dsh/profiles/desktop` is the only active desktop profile. Its package manifest records the built-in and installed plugin bundle order; Electron alone mutates its dependencies, lockfile, and `node_modules`. Production startup rejects a bundle resolved outside this profile, including the CLI-maintained `.dsh/profiles/node_modules` fallback. Package content installed for the desktop profile uses `.dsh/desktop/pnpm/store`. +`.dsh/profiles/desktop` is the only active desktop profile. Its executable package ownership and allowed resolution directories follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Plugin package content uses `.dsh/desktop/pnpm/store`. ## Installation and resolution @@ -71,35 +69,27 @@ The installer never mutates the active profile in place. It copies profile metad The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. -The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the union of the first-party package closures rooted at dsh and the private Desktop Host, lockfile, integrity inventory, and required store subset. Each `mac-arm64`, `mac-x64`, and `win-x64` build owns its packed packages, runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `.desktop-build/targets/`; only the immutable, checksum-verified Node.js download cache is shared. The release build requires the Electron package, root dsh package, and private Host package to have the same version, creates final npm tarballs from the official source build, locally packs the private Host, selects the reachable dsh, Host, and vendored packages plus the Landlock entry, and verifies that the Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`. The Host `files` manifest contains only that runtime entry and overlay, and the package is never published to npm. Public package tarballs remain the official `pnpm pack` results governed by each package's publication manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The seed manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The target Node.js executes bundled pnpm, so pnpm's operating-system and CPU selection makes the materialized dependency graph and seed target-specific. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks the private Host entry and overlay. The build rejects any lockfile that resolves one of the local first-party names by registry version. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both Host files before copying the package set and after offline installation prevents a release whose process entry loads but cannot compose its required overlay from reaching application signing. - -The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation stages every referenced Mach-O content-addressed object and runs at most four independent Developer ID signers concurrently with a secure timestamp and hardened runtime. A signer failure is observed only after every active signer exits and leaves the original CAS objects and package index unchanged. After all signers succeed, preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and verifies every embedded signature. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check. - -Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` plus the installed dsh and Desktop Host packages with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation. - -The plugin GUI performs registry npm-package operations equivalent to `pnpm add --save-exact`, `pnpm remove `, and exact-version update in staging. Every mutation retains the local core-package descriptor, tarballs, dsh and Desktop Host dependencies, and complete override map. Electron validates the installed package manifest and updates the profile's dependency and ordered bundle entries; no renderer request can choose the registry, install directory, lifecycle policy, or arbitrary pnpm flags. - -The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile manifest and npm resolution anchor. The shared profile loader composes its ordered bundle entries, then the private Desktop Host applies its packaged overlay. The Host, dsh, Cordis, desktop plugins, plugin dependencies, and peer dependencies resolve through the ordinary pnpm `node_modules` graph. A desktop plugin contributing `dsh.client` code enters the boot manifest only after the complete profile passes health checking. +Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). A desktop plugin contributing `dsh.client` code enters the client graph only after the complete profile passes health checking. ## Updates and recovery Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release. -Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match. +Before opening the product window, startup validates the current bundled runtime and reconciles external plugins according to the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Backend failure opens the Electron-owned recovery window instead of booting a mismatched dsh release. `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. ## Security and release policy -Core dsh and the private Desktop Host come only from integrity-recorded local npm tarballs inside the signed Electron release; pnpm overrides prevent transitive core packages from falling back to a registry. Store archives are integrity-checked and fully validated in an isolated extraction directory before their files can enter writable package state. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, redacted diagnostics, and health checking are required before activation. +Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation. -Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. -Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. -Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project. +Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. -The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas. +The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas. ## Implementation @@ -107,10 +97,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr |---|---| | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. | | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. | -| Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. | -| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | +| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in Desktop staging. | +| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | -`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. +`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, runtime preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. ## Alternatives considered @@ -122,11 +112,11 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr **Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned. -**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other's dependency graph, Cordis version, plugin version, or native module. The desktop profile therefore owns a complete `node_modules` and rejects bundle resolution through the CLI profile fallback. +**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback. -**Install dsh and plugins into separate desktop projects.** This creates a second resolution anchor and peer-dependency fallback. One ordinary npm project already provides the required installation and resolution model. +**Separate core and plugin resolution without shared package ownership.** That permits duplicate host modules and uncontrolled peer fallback. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supplies explicit links and dependency validation for the separate resource and profile directories. -**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. +**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of runtime space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. **Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token. @@ -136,9 +126,9 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr ## Consequences -- A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session. -- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout. -- `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin. +- A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies. +- The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature. +- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin. - Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`. - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. - The backend and browser application cannot mutate desktop packages. @@ -157,8 +147,8 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | Decision | Recommendation | |---|---| -| First launch | Bundle an offline seed store subset and install it through pnpm | -| Desktop profile | One Electron-owned reserved profile containing exact dsh and plugin dependencies | +| First launch | Validate bundled production packages and create profile links without installing core dependencies | +| Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | | Activation | Staging project, complete health check, journaled directory replacement, one rollback copy | | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | @@ -175,3 +165,7 @@ An npm-installed dsh and desktop dsh may have different versions while sharing d Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move. Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification. + +## Related proposals + +The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supersedes this note’s offline runtime preparation and single-project package ownership. Release identity, signing, portless transport, process ownership, and Electron-only package authorization remain owned here. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 5108492ba6..88e2875cd3 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -10,13 +10,13 @@ DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用 桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。 -当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 +当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 内置 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 ## 决策 交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。 -Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepseek-ai/dsh` 依赖提供后端与匹配的 Web UI,匹配的私有 `@deepseek-ai/dsh-desktop-host` 依赖则只提供 Electron 子进程入口与组合 overlay。dsh 发布、私有 Host 及其第一方依赖闭包使用同一次源码构建生成的本地 npm tarball;profile manifest 把每个核心包列为本地 `file:` 依赖,`pnpm-workspace.yaml` 再通过 overrides 重复该映射。Host 不进入公共 CLI 包,也不会发布到 npm。桌面插件既是同一 profile 中来自 registry 的其他 npm 依赖,也是有序的 `dsh.profile.bundles` 条目,并从该 profile 唯一的 `node_modules` 解析。 +Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。 一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。 @@ -28,7 +28,7 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse |---|---| | Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 | | 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 | -| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` | +| Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 | | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | | 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 | | 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 | @@ -55,15 +55,13 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse package.json pnpm-lock.yaml pnpm-workspace.yaml - desktop-release.json - desktop-packages.json - desktop-packages/ + desktop-runtime-state.json node_modules/ sessions/ storages/ ``` -`.dsh/profiles/desktop` 是唯一活跃的 desktop profile。其 package manifest 记录内置与已安装插件 bundle 的顺序;只有 Electron 可以修改它的依赖、lockfile 和 `node_modules`。生产启动会拒绝解析到该 profile 之外的 bundle,包括 CLI 维护的 `.dsh/profiles/node_modules` fallback。desktop profile 安装的所有包内容都使用 `.dsh/desktop/pnpm/store`。 +`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。插件包内容使用 `.dsh/desktop/pnpm/store`。 ## 安装与解析 @@ -71,33 +69,25 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse 进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 -打包 seed 是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、分别以 dsh 和私有 Desktop Host 为根的第一方包闭包之并集的描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。每个 `mac-arm64`、`mac-x64` 和 `win-x64` 构建都在 `.desktop-build/targets/` 下持有自己的打包输入、运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物;只有不可变且经过校验和验证的 Node.js 下载缓存会被共享。发布构建要求 Electron 包、根 dsh 包与私有 Host 包使用相同版本,从正式源码构建生成最终 npm tarball,在本地打包私有 Host,选择可达的 dsh、Host 与 vendored 包以及 Landlock 入口,并验证 Host tarball 中包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`。Host 的 `files` manifest 只包含该运行入口与 overlay,并且该包不会发布到 npm。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。seed manifest 把每个选中的包列为本地直接依赖,关闭 peer dependency 自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。目标 Node.js 执行内置 pnpm,因此 pnpm 的操作系统和 CPU 选择会使物化的依赖图与 seed 成为目标专用内容。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查私有 Host 的入口与 overlay。构建会拒绝任何通过 registry 版本解析本地第一方包名的 lockfile。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求这两个 Host 文件,可防止进程入口能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。 - -种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 会 staging 每个被引用的内容寻址 Mach-O 对象,最多并发四个独立的 Developer ID 签名进程,并带上安全时间戳与 hardened runtime。任一签名失败后,准备过程会等待已启动的签名进程全部退出,原始 CAS 对象与包索引保持不变。所有签名成功后,准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并验证每个内嵌签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。 - -启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包、已安装 Desktop Host 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的 seed manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。 - -插件 GUI 执行等价于 `pnpm add --save-exact`、`pnpm remove ` 和精确版本更新的 registry npm 包操作。每次修改都保留本地核心包描述文件、tarball、dsh 与 Desktop Host 依赖和完整 override 映射。Electron 验证已安装包 manifest,并更新 profile 的依赖与有序 bundle 条目;任何渲染进程请求都不能选择 registry、安装目录、生命周期策略或任意 pnpm flag。 - -后端与 Loader 把 `.dsh/profiles/desktop/package.json` 作为 profile manifest 和 npm 解析锚点。公共 profile loader 先组合其中的有序 bundle 条目,再由私有 Desktop Host 应用其打包的 overlay。Host、dsh、Cordis、桌面插件、插件依赖和 peer dependency 均通过普通 pnpm `node_modules` 图解析。贡献 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才进入启动 manifest。 +核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。提供 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才会进入客户端依赖图。 ## 更新与恢复 Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 -新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。 +打开产品窗口前,启动过程会验证当前内置运行时,并根据[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)协调外部插件。后端失败会打开 Electron 拥有的恢复窗口,不会启动版本不匹配的 dsh。 `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 ## 安全与发布策略 -核心 dsh 与私有 Desktop Host 只能来自签名 Electron 发布内经过完整性记录的本地 npm tarball;pnpm overrides 防止传递核心包回退到 registry。Store 归档经过完整性检查,并在隔离的解包目录中完成全部验证,归档文件随后才能进入可写包状态。插件安装接受桌面策略允许的 registry 包 spec,但绝不接受原始 pnpm 命令。激活前必须具备精确版本、lockfile 完整性、经过评审的 `allowBuilds` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。 +核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查。 -Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 -Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 -打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。 +打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 @@ -107,10 +97,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool |---|---| | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 | -| 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 | -| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | +| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop staging 中的外部插件依赖图。 | +| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | -`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 +`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、运行时准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 ## 考虑过的替代方案 @@ -122,11 +112,11 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool **复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。 -**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。 +**让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。 -**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。 +**分离核心与插件解析,却不明确共享包归属。** 这会允许宿主模块重复以及不可控的 peer 回退。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)为分离的资源和 profile 目录提供明确链接及依赖验证。 -**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 +**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 运行时空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 **把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。 @@ -136,9 +126,9 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool ## 结果 -- 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。 -- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。 -- `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。 +- 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。 +- 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。 +- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。 - 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 - 后端与浏览器应用不能修改桌面包。 @@ -157,8 +147,8 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool | 决策 | 建议 | |---|---| -| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 | -| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile | +| 首次启动 | 验证内置生产包并创建 profile 链接,不安装核心依赖 | +| 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | | 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | @@ -175,3 +165,7 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 + +## 相关提案 + +[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)取代本记录的离线 运行时准备与单项目包归属决策。发布身份、签名、无端口传输、进程归属及仅限 Electron 的包授权仍由本记录负责。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml new file mode 100644 index 0000000000..9729df077f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 8278107fb3c37aca909541886d9379a1bfd64bac +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b29eb1e6cbe19d4a5b4ea23a5d6159cfd3973bcd diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md new file mode 100644 index 0000000000..8278107fb3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -0,0 +1,51 @@ +# Agent Note: Bundle the Desktop runtime and retain external plugins + +Status: implemented + +English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) + +## Problem + +Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades. + +Separate package directories can load duplicate Cordis or service modules. Retaining plugin files also does not prove compatibility with a new host API or Node runtime. + +## Decision + +[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`. + +Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived. + +## Package ownership + +The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization. + +Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations. + +External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient. + +The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them. + +## Transactions and upgrades + +First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, validates enabled peers, and boots the staged backend. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. + +Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction. + +The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes. + +An Electron-owned recovery window remains available when the backend cannot start. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. + +## Alternatives considered + +- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. +- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. +- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target. +- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable. +- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths. + +## Consequences + +Core package installation is absent from first launch and compatible upgrades. Runtime integrity verification and a complete backend health check still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. + +The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md new file mode 100644 index 0000000000..b29eb1e6cb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 内置 Desktop 运行时并保留外部插件 + +Status: implemented + +[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 + +## 问题 + +Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。 + +分离的包目录可能加载重复的 Cordis 或服务模块。仅保留插件文件也不能证明它与新的宿主 API 或 Node 运行时兼容。 + +## 决策 + +[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。 + +Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档。 + +## 包归属 + +资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。 + +dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。 + +外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。 + +profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们。 + +## 事务与升级 + +首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,验证已启用的 peer,并启动 staging 后端。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 + +依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。 + +事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。 + +后端无法启动时,Electron 拥有的恢复窗口仍可使用。用户可以更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 + +## 考虑过的替代方案 + +- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 +- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 +- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。 +- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。 +- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。 + +## 影响 + +首次启动和兼容升级不安装核心包。运行时完整性验证和完整后端健康检查仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 + +[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml new file mode 100644 index 0000000000..cd64ee4423 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.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/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md +2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7 +2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md new file mode 100644 index 0000000000..e1c2435814 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md @@ -0,0 +1,95 @@ +# Agent Note: Uninstall Desktop while preserving the Harness home + +Status: proposed + +English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md) + +## Problem + +Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage. + +The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory. + +## Proposal + +Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup. + +Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup. + +User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history. + +### 1. Establish ownership and actual paths + +Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths. + +| Candidate | Windows | macOS | Required treatment | +|---|---|---|---| +| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. | +| Installer/update cache | `%LOCALAPPDATA%/` | `~/Library/Caches/` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. | +| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. | +| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. | +| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. | +| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. | + +Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts. + +### 2. Implement shared preservation and cleanup rules + +Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location. + +Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview. + +Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup. + +Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable. + +### 3. Windows implementation + +Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup. + +Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`. + +Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result. + +Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged. + +### 4. macOS implementation + +Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied. + +Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification. + +Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned. + +### 5. Verification and documentation + +Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests. + +Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed. + +Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests. + +## Alternatives considered + +**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome. + +**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here. + +**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation. + +**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations. + +## Acceptance criteria + +- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains. +- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact. +- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data. +- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash. +- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result. +- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion. + +## Risks + +Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention. + +OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md new file mode 100644 index 0000000000..27fd7c8cbe --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 卸载 Desktop 并保留 Harness 主目录 + +Status: proposed + +[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文 + +## 问题 + +用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。 + +[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。 + +## 提案 + +提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。 + +Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。 + +用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。 + +### 1. 确定归属和实际路径 + +使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。 + +| 候选项 | Windows | macOS | 处理要求 | +|---|---|---|---| +| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 | +| 安装器及更新缓存 | `%LOCALAPPDATA%/` | `~/Library/Caches/` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 | +| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 | +| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 | +| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 | +| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 | + +建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。 + +### 2. 实现共用的保留与清理规则 + +按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。 + +拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。 + +阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。 + +不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。 + +### 3. Windows 实现 + +保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。 + +正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。 + +按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。 + +由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。 + +### 4. macOS 实现 + +在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。 + +使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。 + +把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。 + +### 5. 验证和文档 + +增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。 + +在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。 + +为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。 + +## 考虑过的替代方案 + +**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。 + +**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。 + +**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。 + +**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。 + +## 验收标准 + +- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。 +- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。 +- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。 +- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。 +- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。 +- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。 + +## 风险 + +删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。 + +操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index e85badba04..3abebfc3c6 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -149,12 +149,12 @@ function isProjectPath(projectDir: string, target: string): boolean { return path === root || path.startsWith(root + sep) } -function desktopPatches(projectDir: string, allowLinkedPackages: boolean): PatchOptions[] { - const dshRoot = dirname(packageManifestPath(projectDir, '@deepseek-ai/dsh')) +function desktopPatches(runtimeDir: string, projectDir: string, allowLinkedPackages: boolean): PatchOptions[] { + const dshRoot = dirname(packageManifestPath(runtimeDir, '@deepseek-ai/dsh')) const profile = loadProfileDirectory('dsh desktop', projectDir, join(dshRoot, 'package.json')) for (const layer of profile.layers) { - if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir)) { - throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the desktop profile`) + if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir) && !isProjectPath(runtimeDir, layer.packageDir)) { + throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the Desktop runtime and profile`) } } const layers = [ @@ -176,14 +176,14 @@ function desktopPatches(projectDir: string, allowLinkedPackages: boolean): Patch return layers.flat() } -function dshVersion(projectDir: string): string { - const manifest = readManifest(packageManifestPath(projectDir, '@deepseek-ai/dsh')) +function dshVersion(runtimeDir: string): string { + const manifest = readManifest(packageManifestPath(runtimeDir, '@deepseek-ai/dsh')) if (typeof manifest.version !== 'string') throw new Error('dsh desktop: installed dsh manifest has no version') return manifest.version } -function assetHandler(ctx: Context, projectDir: string): ConnectionFetchHandler { - const require = createRequire(join(projectDir, 'package.json')) +function assetHandler(ctx: Context, runtimeDir: string): ConnectionFetchHandler { + const require = createRequire(join(runtimeDir, 'package.json')) const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') const distRoot = realpathSync(dirname(distIndex)) const renderIndex = async (): Promise => { @@ -270,12 +270,14 @@ interface NodeRequestInit extends RequestInit { /** * Boot one installed desktop npm project. + * @param runtimeDir - immutable dsh packages supplied by the Electron application. * @param projectDir - active or staged Electron-owned desktop profile. * @param writeResponse - serialized response-pipe writer that applies byte backpressure. * @param options - development-only allowance for workspace-linked bundle packages. * @returns controller after every Host and client-manifest row is active. */ export async function runDesktopHost( + runtimeDir: string, projectDir: string, writeResponse: (frame: Buffer) => Promise, options: { allowLinkedPackages?: boolean } = {}, @@ -287,6 +289,7 @@ export async function runDesktopHost( const environment = loadLayeredEnv('dsh desktop') let current: Context | undefined const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches( + resolve(runtimeDir), absoluteProject, options.allowLinkedPackages === true, )), (hostCtx) => { @@ -303,7 +306,7 @@ export async function runDesktopHost( throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules') } const api = connection.createSharedFetchHandler('/api') - const assets = assetHandler(ctx, absoluteProject) + const assets = assetHandler(ctx, resolve(runtimeDir)) const streams = remoteStreamHandler(ctx) const requests = new Map() let disposing: Promise | undefined @@ -319,7 +322,7 @@ export async function runDesktopHost( } return { - dshVersion: dshVersion(absoluteProject), + dshVersion: dshVersion(resolve(runtimeDir)), cancel(streamId) { requests.get(streamId)?.abort() }, @@ -374,11 +377,12 @@ export async function runDesktopHost( } async function main(): Promise { - const projectDir = process.argv[2] - if (projectDir === undefined || process.send === undefined) { - throw new Error('dsh desktop: expected project directory, byte pipes, and a Node IPC channel') + const runtimeDir = process.argv[2] + const projectDir = process.argv[3] + if (runtimeDir === undefined || projectDir === undefined || process.send === undefined) { + throw new Error('dsh desktop: expected runtime and profile directories, byte pipes, and a Node IPC channel') } - const option = process.argv[3] + const option = process.argv[4] if (option !== undefined && option !== '--allow-linked-profile') { throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`) } @@ -403,7 +407,7 @@ async function main(): Promise { if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error } } - const controller = await runDesktopHost(projectDir, writeResponse, { allowLinkedPackages: option !== undefined }) + const controller = await runDesktopHost(runtimeDir, projectDir, writeResponse, { allowLinkedPackages: option !== undefined }) send({ type: 'ready', protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index de168b99dd..123b44f866 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: cf350a9d5e9577f5cf52c4371b32f16e278133f9 -README.zh.md: df48eb5903e21cae9ff041626c28d41d12e5c632 +README.md: b2a51010dc37bcca7c7846819b72ababdc227496 +README.zh.md: 241a1e1d900fef49042a7526d29fc473ef568088 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cf350a9d5e..b2a51010dc 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,46 +10,36 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no |---|---|---| | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. | | Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Electron's Node.js, system Node.js, system pnpm, and user package-manager configuration are outside the execution path. | -| Package sources | The exact dsh source build must be packageable before npm publication and install offline; plugins must remain ordinary user-selected npm packages. | The signed application carries locally packed first-party dsh packages and an offline seed store. Desktop plugins remain ordinary npm dependencies resolved from the fixed Desktop registry. | -| Seed transport | Apple notarization inspects code inside archives; shipping every pnpm store file separately would also make the application signature inventory tens of thousands of cache entries, while one compressed archive would amplify small package changes. | macOS packaging signs every Mach-O CAS object, rewrites its pnpm hashes, and proves another offline install before assigning store files to 16 deterministic uncompressed tar shards. The outer installer compresses them, and differential updates can reuse unchanged shards. | +| Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. | +| Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. | | Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. | -| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh seed, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | +| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions. ## Installation ownership -Electron owns the reserved profile at `$DSH_HOME/profiles/desktop`. Its manifest lists the built-in and installed plugin bundles in `dsh.profile.bundles`, while its `node_modules` contains the exact `@deepseek-ai/dsh` release, its matching private `@deepseek-ai/dsh-desktop-host`, and every desktop plugin. Keeping the Electron-only process entry and overlay in a private app package prevents Desktop implementation from becoming part of the public CLI package. The CLI cannot boot or mutate this profile. Electron always invokes its bundled Node.js and pnpm with the store at `$DSH_HOME/desktop/pnpm/store`; it never uses system pnpm or the caller's npm/pnpm configuration. +Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. -### Seed installation +### Runtime and plugin activation -The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, materializes the production graph online with lifecycle scripts disabled, deletes `node_modules` and every temporary pnpm cache, config, and state directory, and proves one complete installation offline from the final store alone with the private Desktop Host entry and overlay present. A macOS build stages every Mach-O object from pnpm's content-addressed store, Developer ID signs at most four independent copies concurrently, and updates the affected SHA-512 index records only after all signers succeed. Another offline install proves the rewritten store before sharding; preparation then extracts the final archives and verifies every embedded signature. The signed seed retains the release identity, local first-party tarballs and their descriptor, project metadata, lockfile, integrity inventory, and pnpm store content required to repeat that installation on the user's machine. +The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup verifies the tree before creating host links. Core packages are never copied into profile storage or installed by pnpm at first launch. -| Seed content | Writable destination or use | -|---|---| -| `integrity.json` and `desktop-packages.json` | Verify every inventoried seed file, local tarball hash, and the bound dsh and Desktop Host versions before package state changes. | -| `store-archives.json` and `store-archives/*.tar` | Validate the deterministic uncompressed shards, extract them into a unique Desktop staging directory, replace matching immutable store files, and transactionally merge pnpm's versioned SQLite package index into `$DSH_HOME/desktop/pnpm/store` without removing packages already downloaded for Desktop plugins. | -| Project metadata and `desktop-packages/` | Copy into a unique `$DSH_HOME/desktop/staging//profile` project. | -| Lockfile and local package mappings | Drive the bundled pnpm installation without resolving a packaged core name from npm. | +1. A fresh profile creates its manifest and shared package links, then boots and stops the complete backend before activation. +2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, checks enabled plugins’ peer requirements, and runs the same backend health check. Plugin versions and lockfile remain unchanged; pnpm does not run. +3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. +4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. +5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. A failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. -Startup installs or reconciles the seed as one serialized transaction: +The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. -1. Recover an interrupted activation journal, verify the complete seed inventory and local package set, and require the seed version to equal Electron's application version. -2. If the active profile already contains that release plus the matching dsh and Desktop Host versions, verify its local package set and reuse it without reinstalling. -3. Otherwise validate every archive entry, extract all store shards into a temporary Desktop-owned staging directory, merge the package files and SQLite package-index records into the private store, create a staging profile, and run `pnpm install --offline --frozen-lockfile --trust-lockfile` through the bundled Node.js and pnpm. Seed records replace matching index keys while plugin-only records remain available. -4. During an Electron upgrade, read every plugin name and exact version from the old active profile and add those versions to staging with `--offline` from existing Desktop pnpm state. A first installation has no plugin-restore step. -5. Stop the active backend, boot and stop the complete staged backend as a health check, then restart the active backend before activation. This serialization prevents two desktop backends from sharing `$DSH_HOME`; installation or plugin incompatibility before activation deletes staging and leaves the active profile unchanged. -6. Persist each next activation phase before its directory move, move the active profile to `$DSH_HOME/desktop/rollback/profile`, and move staging into `$DSH_HOME/profiles/desktop`. Recovery combines the journal with the actual profile, rollback, and staging directories, so interruption in either write-to-move gap restores or retains a complete profile. - -GUI plugin mutations use the same staging, health-check, activation, and rollback path after installing registry packages into the shared Desktop pnpm store. - -The process-lifetime Electron lock is the primary desktop owner. The transaction lock is depth defense: it records Electron while preparing local state, records the spawned pnpm worker while that worker can still write, and returns ownership to Electron after the worker exits. A later process cannot treat a live orphaned worker as a stale transaction. +Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; hardlinks are unsuitable because they cannot represent package directories and would retain old file content after application replacement. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. ## Develop @@ -67,7 +57,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, release seed, plugin installation, staging, and rollback paths. +Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation, staging, and rollback paths. ## Package @@ -88,7 +78,7 @@ export APPLE_API_ISSUER='' pnpm run package:desktop ``` -Release automation uses fixed target commands so runtime preparation, seed installation, and electron-builder receive the same platform and architecture: +Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: ```sh pnpm run package:desktop:mac:arm64 @@ -98,7 +88,7 @@ pnpm run package:desktop:win:x64 The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. -Each target owns its packed package inputs, prepared runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. +Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. ### Upload updates @@ -125,7 +115,7 @@ pnpm run upload:mac:arm64 Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename. -The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Seed preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. ### Windows EV signing @@ -141,7 +131,7 @@ pnpm run package:desktop:win:x64 Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: @@ -156,21 +146,21 @@ To inspect or troubleshoot the prepared host-target resources without invoking e pnpm run prepare:desktop ``` -This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or seed content. +This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. -Every package command performs the official repository build, packs the dsh and vendored package families, locally packs the private Desktop Host package, and packs the Landlock entry before preparing release resources. `prepare:packages` selects the union of the first-party production closures rooted at `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host`, verifies that the private Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`, copies the selected tarballs into the seed input, and records their sizes and SHA-512 integrity. The Host package is never published to npm; its `files` manifest contains only that runtime entry and overlay. Public package tarballs remain the official `pnpm pack` outputs governed by each package's publication manifest, so Desktop adds no second filter, retains published declarations such as `lib/types`, and neither strips nor adds source maps independently. Registry packages likewise retain their published package bytes in pnpm's content-addressed store. The dsh release bump updates both private Desktop manifests together with the root and publishable workspaces; packaging also requires the root dsh package, Desktop Host package, and Electron package to have the same version. Neither dsh nor the private Host needs to be published to npm before the Desktop application is built. `prepare:runtime` downloads Node.js 24.17.0 from the official Node.js release service, verifies its SHA-256 entry before extraction, and executes the prepared target binary on a compatible build host to verify its reported version. It copies the pnpm version declared by the desktop package and records both runtime versions in the release seed. `prepare:seed` runs that target Node.js and bundled pnpm, so platform- and CPU-filtered optional dependencies make the pnpm store and seed target-specific. It generates local core-package mappings, disables the global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and all temporary pnpm cache, config, and state, proves the complete graph installs offline with the private Host entry and overlay, performs the macOS rewrite when applicable, proves the rewritten store with another offline installation, removes temporary pnpm project registrations, and replaces the loose store with 16 deterministic uncompressed tar shards. It extracts those final shards and verifies every embedded macOS signature before inventory generation. Later GUI plugin operations retain the local core mappings while resolving plugin packages and their external dependencies from the fixed Desktop npm registry. `electron-builder` emits each target's platform artifacts under `apps/desktop/.desktop-build/targets//artifacts`; a later version keeps differently named immutable installers and blockmaps while replacing that target's unpacked application, diagnostics, completion record, and channel metadata. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares target-specific Node and pnpm executables. `prepare:dsh` installs the production graph once at build time, copies materialized packages into `extraResources/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the copied inventory is checked before signing and again after signing. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. -An unpacked artifact contains four independent size contributors: Electron, the offline seed store shards and local dsh tarballs, the upstream Node.js and pnpm runtime, and the small shell application. The shards are uncompressed so the outer DMG, ZIP, or NSIS compressor and differential updater can operate on stable ranges. Filesystem size is not installer download size, so measure both separately. First packaged startup also extracts the seed store into `$DSH_HOME/desktop/pnpm/store` before installing the writable profile, so release qualification must measure both application and Harness-home disk use. +An unpacked artifact contains Electron, the materialized dsh production tree, upstream Node.js and pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. ## Updates -A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound seed before reopening the product window. +A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound runtime before reopening the product window. -Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The seed and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. +Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. ## Low-level development overrides -`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_SEED_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`. +`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index df48eb5903..241a1e1d90 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,46 +10,36 @@ |---|---|---| | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 | | 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和用户包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。Electron 的 Node.js、系统 Node.js、系统 pnpm 与用户的包管理器配置都不进入执行路径。 | -| 包来源 | 必须能在发布到 npm 之前从同一次源码构建打包精确的 dsh,并支持离线安装;插件则需要保留为用户选择的普通 npm 包。 | 已签名应用携带本地打包的第一方 dsh 包与离线 seed store。桌面插件仍是从固定 Desktop registry 解析的普通 npm 依赖。 | -| Seed 传输 | Apple 公证会检查归档内的代码;把 pnpm store 的每个文件分别放入应用,还会让应用签名记录数万个缓存条目,而单个压缩归档会放大小幅包变更。 | macOS 打包先签署每个 Mach-O CAS 对象、重写其 pnpm 哈希并再次证明离线安装,再把 store 文件分配到 16 个确定性的未压缩 tar 分片。外层安装包负责压缩,差分更新可以复用未变化的分片。 | +| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 | +| 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 | | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 | | 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 | -| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh seed、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | +| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。 ## 安装归属 -Electron 拥有保留 profile `$DSH_HOME/profiles/desktop`。其 manifest 通过 `dsh.profile.bundles` 列出内置与已安装插件 bundle,`node_modules` 则同时包含精确版本的 `@deepseek-ai/dsh`、与之匹配的私有 `@deepseek-ai/dsh-desktop-host` 和所有桌面插件。把 Electron 专用进程入口与 overlay 放入私有应用包,可以避免 Desktop 实现成为公共 CLI 包的一部分。CLI 不能启动或修改该 profile。Electron 始终调用自身内置的 Node.js 与 pnpm,并把 store 固定在 `$DSH_HOME/desktop/pnpm/store`;它绝不使用系统 pnpm 或调用方的 npm/pnpm 配置。 +Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 -### Seed 安装 +### 运行时与插件激活 -安装包内的 seed 是安装工具包,不是可以直接运行的 `node_modules` 目录。打包过程会生成锁文件,在禁用生命周期脚本的情况下在线物化生产依赖图,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 完成一次完整离线安装,并验证私有 Desktop Host 的入口与 overlay 均存在。macOS 构建随后从 pnpm 内容寻址 store staging 每个 Mach-O 对象,最多并发四个 Developer ID 签名进程,并且只在所有签名成功后才更新受影响的 SHA-512 索引记录。再一次离线安装会在分片前证明重写后的 store;准备过程随后解包最终归档,并验证每个内嵌签名。签名 seed 保留发布身份、本地第一方 tarball 及其描述文件、项目元数据、锁文件、完整性清单,以及在用户机器上重复该安装所需的 pnpm store 内容。 +签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动会先验证依赖树,再创建宿主链接。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 -| Seed 内容 | 可写目标或用途 | -|---|---| -| `integrity.json` 与 `desktop-packages.json` | 在修改包状态前验证清单记录的每个 seed 文件、本地 tarball 哈希以及绑定的 dsh 与 Desktop Host 版本。 | -| `store-archives.json` 与 `store-archives/*.tar` | 验证确定性的未压缩分片,把它们解包到唯一的 Desktop staging 目录,替换匹配的不可变 store 文件,并以事务方式把 pnpm 的版本化 SQLite 包索引合并进 `$DSH_HOME/desktop/pnpm/store`,且不移除已经为 Desktop 插件下载的包。 | -| 项目元数据与 `desktop-packages/` | 复制到唯一的 `$DSH_HOME/desktop/staging//profile` 项目。 | -| 锁文件与本地包映射 | 驱动内置 pnpm 完成安装,且不会从 npm 解析已打包的核心包名。 | +1. 新 profile 创建 manifest 和共享包链接,然后启动并停止完整后端,通过后才激活。 +2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求,并执行相同的后端健康检查。插件版本和锁文件保持不变;不运行 pnpm。 +3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 +4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 +5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 -启动过程把 seed 安装或校准为一个串行事务: +后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 -1. 恢复中断的激活事务日志,验证完整 seed 清单与本地包集,并要求 seed 版本等于 Electron 应用版本。 -2. 如果活跃 profile 已包含该发布及匹配的 dsh 与 Desktop Host 版本,则验证其中的本地包集并直接复用,不重新安装。 -3. 否则验证每个归档条目,把全部 store 分片解包到 Desktop 拥有的临时 staging 目录,将包文件与 SQLite 包索引记录合并进私有 store,再创建 staging profile,并通过内置 Node.js 与 pnpm 执行 `pnpm install --offline --frozen-lockfile --trust-lockfile`。Seed 记录替换匹配的索引键,插件专属记录继续保留。 -4. Electron 升级时,从旧活跃 profile 读取每个插件的名称和精确版本,再通过现有 Desktop pnpm 状态以 `--offline` 把这些版本加入 staging。首次安装不执行插件恢复。 -5. 停止活跃后端,启动并停止完整的 staging 后端执行健康检查,再在激活前重新启动活跃后端。这种串行方式避免两个桌面后端共享 `$DSH_HOME`;安装错误或插件不兼容会删除 staging,并保持活跃 profile 不变。 -6. 在每次目录移动前先持久化下一个激活阶段,把活跃 profile 移到 `$DSH_HOME/desktop/rollback/profile`,再把 staging 移到 `$DSH_HOME/profiles/desktop`。恢复过程同时检查日志与真实的 profile、rollback 和 staging 目录,因此在任一个写入与移动间隙中断后仍会恢复或保留一个完整 profile。 - -GUI 插件修改会在把 registry 包安装到共享 Desktop pnpm store 后,使用相同的 staging、健康检查、激活与 rollback 路径。 - -进程生命周期 Electron 锁是桌面端的主要 owner。事务锁用于纵深防御:准备本地状态时记录 Electron,在 pnpm worker 仍可能写入时记录该 worker,worker 退出后再把 owner 交还 Electron。后续进程不会把仍然存活的孤儿 worker 误判为陈旧事务。 +包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;硬链接无法表示包目录,而且应用替换后仍会保留旧文件内容,因此不适合此用途。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 ## 开发 @@ -67,7 +57,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、发布 seed、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 +Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 ## 打包 @@ -88,7 +78,7 @@ export APPLE_API_ISSUER='' pnpm run package:desktop ``` -发布自动化使用固定目标命令,确保运行时准备、seed 安装与 electron-builder 接收相同的平台和架构: +发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: ```sh pnpm run package:desktop:mac:arm64 @@ -98,7 +88,7 @@ pnpm run package:desktop:win:x64 macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Desktop 尚不支持 Linux 发布目标。 -每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 +每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 ### 上传更新 @@ -125,7 +115,7 @@ pnpm run upload:mac:arm64 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证 hook 成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。 -macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。Seed 准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 ### Windows EV 签名 @@ -141,7 +131,7 @@ pnpm run package:desktop:win:x64 打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。 -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: @@ -156,21 +146,21 @@ pnpm run package:desktop:mac:arm64:dir pnpm run prepare:desktop ``` -这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 seed 内容。 +这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 -每条打包命令都会先执行仓库的正式构建,打包 dsh 与 vendored 包族,在本地打包私有 Desktop Host 包,并打包 Landlock 入口,然后再准备发布资源。`prepare:packages` 选择分别以 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-desktop-host` 为根的第一方生产依赖闭包之并集,验证私有 Host tarball 同时包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`,把选中的 tarball 复制到 seed 输入,并记录其大小与 SHA-512 完整性。Host 包不会发布到 npm;它的 `files` manifest 只包含该运行入口与 overlay。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 输出,因此 Desktop 不增加第二套过滤规则,会保留 `lib/types` 等已发布声明,也不会独立删除或增加 source map。Registry 包同样在 pnpm 内容寻址 store 中保留其发布的包字节。dsh 发布版本更新会同步更新两个私有 Desktop manifest、仓库根与可发布 workspace;打包还会要求根 dsh 包、Desktop Host 包与 Electron 包使用同一版本。构建 Desktop 应用前不要求 dsh 或私有 Host 已发布到 npm。`prepare:runtime` 从 Node.js 官方发行服务下载 Node.js 24.17.0,在解压前验证其 SHA-256 条目,并在兼容的构建宿主上执行准备完成的目标二进制文件以验证其报告版本。它复制桌面包声明的 pnpm 版本,并把两个运行时版本记录进发布 seed。`prepare:seed` 运行该目标 Node.js 与内置 pnpm,因此按平台和 CPU 过滤的可选依赖会使 pnpm store 与 seed 成为目标专用内容。它生成本地核心包映射、禁用全局 virtual store、从 npm 物化外部生产依赖并禁用生命周期脚本、删除 `node_modules` 以及所有临时 pnpm cache、config 和 state,证明完整依赖图可以离线安装并包含私有 Host 的入口与 overlay,在适用时执行 macOS 重写,再通过一次离线安装证明重写后的 store,删除临时 pnpm 项目注册,然后把松散 store 替换为 16 个确定性的未压缩 tar 分片。它会解包这些最终分片,并在生成清单前验证每个内嵌 macOS 签名。后续 GUI 插件操作保留本地核心包映射,同时从固定的 Desktop npm registry 解析插件包及其外部依赖。`electron-builder` 把各目标的平台产物写到 `apps/desktop/.desktop-build/targets//artifacts`;后续版本会保留不同名称的不可变安装包与 blockmap,但会替换该目标的未打包应用、诊断文件、完成记录与频道元数据。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Node 与 pnpm 可执行文件。`prepare:dsh` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 -未压缩产物包含四块相互独立的体积:Electron、离线 seed store 分片与本地 dsh tarball、上游 Node.js 与 pnpm 运行时,以及很小的桌面壳应用。分片不压缩,使外层 DMG、ZIP 或 NSIS 压缩器与差分更新器可以处理稳定的数据区间。文件系统占用不等于安装包下载大小,因此必须分别测量。打包应用首次启动时还会先把 seed store 解包到 `$DSH_HOME/desktop/pnpm/store`,再安装可写 profile,因此发布验证必须同时测量应用与 Harness home 的磁盘占用。 +未压缩产物包含 Electron、物化后的 dsh 生产依赖树、上游 Node.js 与 pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 ## 更新 -打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。 +打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的运行时,再重新打开产品窗口。 -Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。Seed 与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 +Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 ## 底层开发覆盖项 -`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_SEED_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 +`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 ## 已知限制 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 2f4b88de64..760f8cb8a1 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -6,12 +6,14 @@ export interface DesktopElectronBuilderConfig { } readonly extraResources: readonly [ { readonly from: string, readonly to: 'runtime' }, - { readonly from: string, readonly to: 'seed' }, + { readonly from: string, readonly to: 'dsh' }, + { readonly from: string, readonly to: 'dsh/node_modules' }, ] readonly mac: { readonly identity: string | undefined readonly forceCodeSigning: boolean readonly notarize: boolean + readonly signIgnore: readonly string[] } readonly dmg: { readonly sign: boolean diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index c47bd6d7a9..d082658695 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, @@ -59,13 +60,17 @@ export function createElectronBuilderConfig( ], extraResources: [ { from: buildPaths.runtime, to: 'runtime' }, - { from: buildPaths.seed, to: 'seed' }, + { from: buildPaths.dsh, to: 'dsh' }, + // electron-builder excludes a source directory's root node_modules. + { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules' }, ], mac: { category: 'public.app-category.developer-tools', identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + // Runtime files are signed before their integrity inventory is sealed. + signIgnore: ['/Contents/Resources/dsh(?:/|$)'], notarize: true, target: ['dmg', 'zip'], }, @@ -73,8 +78,16 @@ export function createElectronBuilderConfig( sign: true, writeUpdateInfo: false, }, - afterSign: context => { + afterPack: async context => { + const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') + verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'), + context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + }, + afterSign: async context => { if (context.electronPlatformName !== 'darwin') return + const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') + verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'), + context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch }) verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) }, artifactBuildCompleted: artifact => { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ec0b802a17..e8d38981c5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-desktop", - "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime", + "description": "Electron desktop shell for a bundled dsh runtime and external plugins", "version": "0.1.3-alpha.2", "private": true, "license": "MIT", @@ -12,7 +12,7 @@ "start": "tsx scripts/dev.ts --skip-build", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "prepare:packages": "tsx scripts/prepare-package-set.ts", - "prepare:seed": "tsx scripts/prepare-seed.ts", + "prepare:dsh": "tsx scripts/prepare-dsh.ts", "prepare:package": "tsx scripts/package-target.ts --prepare-only", "verify:mac-signature": "node scripts/verify-macos-signature.mjs", "package": "tsx scripts/package-target.ts", @@ -43,7 +43,6 @@ "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", "js-yaml": "^4.2.0", - "msgpackr": "2.0.4", "pnpm": "11.7.0", "tar": "^7.5.0", "typescript": "^6.0.3" diff --git a/apps/desktop/renderer/plugin-manager.html b/apps/desktop/renderer/plugin-manager.html index 03fbb4b8aa..112fd1825f 100644 --- a/apps/desktop/renderer/plugin-manager.html +++ b/apps/desktop/renderer/plugin-manager.html @@ -16,6 +16,12 @@ +
diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js index a50de3a825..6599c5b9d5 100644 --- a/apps/desktop/renderer/plugin-manager.js +++ b/apps/desktop/renderer/plugin-manager.js @@ -14,6 +14,10 @@ async function main() { document.querySelector('#installed-heading').textContent = messages.installed document.querySelector('#empty').textContent = messages.noPlugins + document.querySelector('#recovery-description').textContent = messages.recoveryDescription + document.querySelector('#retry').textContent = messages.retry + document.querySelector('#disable-all').textContent = messages.disableAll + const list = document.querySelector('#plugins') const empty = document.querySelector('#empty') const status = document.querySelector('#status') @@ -27,13 +31,16 @@ async function main() { } async function render() { + const backend = await api.backend.status() + document.querySelector('#recovery').hidden = backend.ready + document.querySelector('#startup-error').textContent = backend.error ?? '' const plugins = await api.plugins.list() list.replaceChildren(...plugins.map(plugin => { const item = document.createElement('li') const identity = document.createElement('span') const version = document.createElement('span') version.className = 'package-version' - version.textContent = plugin.version + version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}` identity.append(document.createTextNode(plugin.name), version) const remove = document.createElement('button') remove.type = 'button' @@ -52,7 +59,13 @@ async function main() { }) const actions = document.createElement('span') actions.className = 'package-actions' - actions.append(update, remove) + const toggle = document.createElement('button') + toggle.type = 'button' + toggle.textContent = plugin.enabled ? messages.disable : messages.enable + toggle.addEventListener('click', () => void run( + () => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation, + )) + actions.append(toggle, update, remove) item.append(identity, actions) return item })) @@ -93,6 +106,8 @@ async function main() { input.value = '' }, message('installing', { spec })) }) + document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry)) + document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation)) refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed)) await load(messages.loadingPlugins, '') diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index 423e017b15..add677f668 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -6,8 +6,8 @@ export interface DesktopTargetBuildPaths { readonly artifacts: string readonly runtime: string readonly packageSet: string - readonly seed: string - readonly seedPnpm: string + readonly dsh: string + readonly dshPnpm: string readonly nodeExtract: string readonly packedDsh: string readonly packedVendor: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 504136e1db..770b3333e3 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -31,7 +31,7 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { if (!SUPPORTED_TARGETS.has(target)) { @@ -44,8 +44,8 @@ export function desktopTargetBuildPaths(target) { artifacts: join(root, 'artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), - seed: join(root, 'seed'), - seedPnpm: join(root, 'seed-pnpm'), + dsh: join(root, 'dsh'), + dshPnpm: join(root, 'dsh-pnpm'), nodeExtract: join(root, 'node-extract'), packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts new file mode 100644 index 0000000000..313c9cdf11 --- /dev/null +++ b/apps/desktop/scripts/macos-runtime.ts @@ -0,0 +1,43 @@ +/** Sign final native runtime files before the enclosing Desktop application is signed. */ + +import { createHash } from 'node:crypto' +import { closeSync, openSync, readSync } from 'node:fs' +import { join } from 'node:path' +import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' +import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' + +const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca']) + +function isMachO(path: string): boolean { + const descriptor = openSync(path, 'r') + try { + const header = Buffer.alloc(4) + return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex')) + } finally { closeSync(descriptor) } +} + +/** + * Sign and verify every materialized Mach-O file, awaiting all signers on failure. + * @param root - Self-contained production runtime without symlinks. + * @param appId - Release application identifier. + * @param expected - Required signing identity. + * @returns Number of signed native files. + */ +export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise { + const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path))) + let next = 0 + const workers = Array.from({ length: Math.min(4, files.length) }, async () => { + for (;;) { + const path = files[next++] + if (path === undefined) return + const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` + await signMacOSRuntimeCode(join(root, path), identifier, expected) + verifyMacOSRuntimeCode(join(root, path), expected) + } + }) + const results = await Promise.allSettled(workers) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed') + return files.length +} diff --git a/apps/desktop/scripts/macos-seed-store.ts b/apps/desktop/scripts/macos-seed-store.ts deleted file mode 100644 index 1a02dfd5cc..0000000000 --- a/apps/desktop/scripts/macos-seed-store.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** Sign Mach-O content in a pnpm CAS without invalidating the store index. */ - -import { createHash } from 'node:crypto' -import { - chmodSync, - closeSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - openSync, - readFileSync, - readSync, - readdirSync, - rmSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { availableParallelism, tmpdir } from 'node:os' -import { basename, dirname, join, relative, sep } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { Packr } from 'msgpackr' -import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' -import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs' - -const MACH_O_MAGICS = new Set([ - 'cafebabe', - 'cafebabf', - 'cefaedfe', - 'cffaedfe', - 'feedface', - 'feedfacf', - 'bebafeca', - 'bfbafeca', -]) -const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u -const MAX_CONCURRENT_CODE_SIGNERS = 4 -const packr = new Packr({ moreTypes: true, useRecords: true }) - -interface PnpmStoreFileRecord { - checkedAt: number - digest: string - mode: number - size: number -} - -interface PnpmSideEffectsRecord { - readonly added?: Map -} - -interface PnpmPackageIndexRecord { - readonly algo?: string - readonly files?: Map - readonly sideEffects?: Map -} - -interface DecodedIndexRow { - readonly key: string - readonly value: PnpmPackageIndexRecord - changed: boolean -} - -interface CasFile { - readonly path: string - readonly digest: string - readonly executable: boolean -} - -interface FileReference { - readonly row: DecodedIndexRow - readonly record: PnpmStoreFileRecord -} - -interface SigningWork { - readonly file: CasFile - readonly references: readonly FileReference[] - readonly temporaryPath: string -} - -/** Summary of native code rewritten in one pnpm store. */ -export interface MacOSSeedStoreSigningResult { - readonly signedFiles: number - readonly prunedOrphans: number - readonly updatedIndexRows: number -} - -/** A signer used to make one writable Mach-O copy release-valid. */ -export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise - -/** A verifier used to check one Mach-O file after packaging transport. */ -export type MacOSSeedCodeVerifier = (path: string) => void - -/** Optional execution controls for seed-store code signing. */ -export interface MacOSSeedStoreSigningOptions { - readonly signer?: MacOSSeedCodeSigner - readonly concurrency?: number -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord { - if (!isRecord(value)) return false - return typeof value.checkedAt === 'number' - && typeof value.digest === 'string' - && /^[0-9a-f]{128}$/u.test(value.digest) - && Number.isSafeInteger(value.mode) - && Number.isSafeInteger(value.size) -} - -function packageFileMaps(value: unknown, key: string): readonly Map[] { - if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`) - const record = value as PnpmPackageIndexRecord - if (record.algo !== undefined && record.algo !== 'sha512') { - throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`) - } - const maps: Map[] = [] - if (record.files !== undefined) { - if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`) - maps.push(record.files) - } - if (record.sideEffects !== undefined) { - if (!(record.sideEffects instanceof Map)) { - throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`) - } - for (const effect of record.sideEffects.values()) { - if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`) - if (effect.added === undefined) continue - if (!(effect.added instanceof Map)) { - throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`) - } - maps.push(effect.added) - } - } - for (const files of maps) { - for (const file of files.values()) { - if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`) - } - } - return maps -} - -function isExecutableMode(mode: number): boolean { - return (mode & 0o111) !== 0 -} - -function referenceKey(digest: string, executable: boolean): string { - return `${digest}:${executable ? 'exec' : 'nonexec'}` -} - -function isMachO(path: string): boolean { - const descriptor = openSync(path, 'r') - try { - const header = Buffer.alloc(4) - return readSync(descriptor, header, 0, header.length, 0) === header.length - && MACH_O_MAGICS.has(header.toString('hex')) - } finally { - closeSync(descriptor) - } -} - -function visitFiles(root: string): readonly string[] { - const files: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`) - } - if (entry.isDirectory()) visit(path) - else if (entry.isFile()) files.push(path) - else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`) - } - } - visit(root) - return files.sort((left, right) => left.localeCompare(right)) -} - -function versionRoots(storeRoot: string): readonly string[] { - return readdirSync(storeRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name)) - .map(entry => join(storeRoot, entry.name)) - .filter(root => existsSync(join(root, 'files'))) - .sort((left, right) => left.localeCompare(right)) -} - -function casFiles(versionRoot: string): readonly CasFile[] { - const filesRoot = join(versionRoot, 'files') - const result: CasFile[] = [] - for (const path of visitFiles(filesRoot)) { - if (!isMachO(path)) continue - const normalized = relative(filesRoot, path).split(sep).join('/') - const match = CAS_PATH_PATTERN.exec(normalized) - if (match === null) { - throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`) - } - result.push({ - path, - digest: `${match[1]}${match[2]}`, - executable: match[3] !== undefined, - }) - } - return result -} - -function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] { - const rows: DecodedIndexRow[] = [] - for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{ - key: string - data: Uint8Array - }>) { - rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false }) - } - return rows -} - -function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap { - const references = new Map() - for (const row of rows) { - for (const files of packageFileMaps(row.value, row.key)) { - for (const record of files.values()) { - const key = referenceKey(record.digest, isExecutableMode(record.mode)) - const values = references.get(key) ?? [] - values.push({ row, record }) - references.set(key, values) - } - } - } - return references -} - -function writeCasFile(path: string, body: Buffer, mode: number): void { - mkdirSync(dirname(path), { recursive: true }) - try { - writeFileSync(path, body, { flag: 'wx', mode }) - } catch (error) { - if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error - } - chmodSync(path, mode) -} - -function signedCasPath(versionRoot: string, digest: string, executable: boolean): string { - return join( - versionRoot, - 'files', - digest.slice(0, 2), - `${digest.slice(2)}${executable ? '-exec' : ''}`, - ) -} - -async function runConcurrent( - values: readonly T[], - concurrency: number, - run: (value: T) => Promise, -): Promise { - let next = 0 - const failure: { error?: unknown; failed: boolean } = { failed: false } - const worker = async (): Promise => { - while (!failure.failed) { - const index = next - if (index >= values.length) return - next += 1 - try { - await run(values[index] as T) - } catch (error) { - if (!failure.failed) { - failure.failed = true - failure.error = error - } - } - } - } - const workers = Array.from( - { length: Math.min(concurrency, values.length) }, - async () => worker(), - ) - await Promise.all(workers) - if (failure.failed) throw failure.error -} - -async function rewriteVersionStore( - versionRoot: string, - appId: string, - signer: MacOSSeedCodeSigner, - concurrency: number, -): Promise { - const databasePath = join(versionRoot, 'index.db') - if (!existsSync(databasePath)) { - throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`) - } - const database = new DatabaseSync(databasePath) - const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-')) - const obsoleteFiles = new Set() - let prunedOrphans = 0 - let rows: readonly DecodedIndexRow[] = [] - try { - rows = readIndexRows(database) - const references = fileReferences(rows) - const signingWork: SigningWork[] = [] - for (const file of casFiles(versionRoot)) { - const body = readFileSync(file.path) - const actualDigest = createHash('sha512').update(body).digest('hex') - if (actualDigest !== file.digest) { - throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`) - } - const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? [] - if (fileReferences.length === 0) { - obsoleteFiles.add(file.path) - prunedOrphans += 1 - continue - } - const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`) - copyFileSync(file.path, temporary) - chmodSync(temporary, 0o755) - signingWork.push({ file, references: fileReferences, temporaryPath: temporary }) - } - await runConcurrent(signingWork, concurrency, async (work) => { - await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`) - }) - for (const work of signingWork) { - const signedBody = readFileSync(work.temporaryPath) - if (!isMachO(work.temporaryPath)) { - throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`) - } - const signedDigest = createHash('sha512').update(signedBody).digest('hex') - const mode = work.file.executable ? 0o755 : 0o644 - const destination = signedCasPath(versionRoot, signedDigest, work.file.executable) - writeCasFile(destination, signedBody, mode) - const checkedAt = Date.now() - for (const reference of work.references) { - reference.record.checkedAt = checkedAt - reference.record.digest = signedDigest - reference.record.mode = mode - reference.record.size = signedBody.length - reference.row.changed = true - } - if (destination !== work.file.path) obsoleteFiles.add(work.file.path) - } - const changedRows = rows.filter(row => row.changed) - database.exec('BEGIN IMMEDIATE') - let committed = false - try { - const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)') - for (const row of changedRows) statement.run(row.key, packr.pack(row.value)) - database.exec('COMMIT') - committed = true - } finally { - if (!committed) database.exec('ROLLBACK') - } - for (const path of obsoleteFiles) unlinkSync(path) - database.exec('VACUUM') - return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length } - } finally { - database.close() - rmSync(workRoot, { recursive: true, force: true }) - } -} - -/** - * Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index. - * A signer rejection leaves the original CAS objects and package index unchanged. - * @param storeRoot - Loose pnpm store prepared for the packaged seed. - * @param appId - Electron application ID used as the signing identifier prefix. - * @param expected - Company Developer ID identity and Team ID. - * @param options - Optional signer and worker bound used by focused tests. - * @returns Counts for release diagnostics after every signer completes and the index transaction commits. - */ -export async function signMacOSSeedStore( - storeRoot: string, - appId: string, - expected: MacOSSigningEnvironment, - options: MacOSSeedStoreSigningOptions = {}, -): Promise { - const roots = versionRoots(storeRoot) - if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`) - const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism()) - if (!Number.isSafeInteger(concurrency) || concurrency < 1) { - throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`) - } - const signer = options.signer ?? (async (path, identifier) => { - await signMacOSSeedCode(path, identifier, expected) - }) - const results: MacOSSeedStoreSigningResult[] = [] - for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency)) - return results.reduce((total, current) => ({ - signedFiles: total.signedFiles + current.signedFiles, - prunedOrphans: total.prunedOrphans + current.prunedOrphans, - updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows, - }), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 }) -} - -/** - * Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime. - * @param storeRoot - Loose or extracted pnpm store. - * @param expected - Company Developer ID identity and Team ID. - * @param verifier - Injectable signature verifier used by focused tests. - * @returns Number of verified Mach-O files. - */ -export function verifyMacOSSeedStore( - storeRoot: string, - expected: MacOSSigningEnvironment, - verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) }, -): number { - let count = 0 - for (const root of versionRoots(storeRoot)) { - for (const file of casFiles(root)) { - verifier(file.path) - count += 1 - } - } - return count -} diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 1f32123ec0..c5b0bc2667 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -1,4 +1,4 @@ -/** Build one release target with matching Electron, Node.js, and seed architecture. */ +/** Build one release target with matching Electron, Node.js, and dsh architecture. */ import { spawn } from 'node:child_process' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' @@ -276,7 +276,7 @@ async function main(): Promise { ], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'prepare:runtime'], targetEnv) await runPnpm(['run', 'prepare:packages'], targetEnv) - await runPnpm(['run', 'prepare:seed'], targetEnv) + await runPnpm(['run', 'prepare:dsh'], targetEnv) if (invocation.prepareOnly) return await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts new file mode 100644 index 0000000000..d030a05e1a --- /dev/null +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -0,0 +1,150 @@ +/** Materialize the complete production runtime before publishing Desktop resources. */ + +import { spawn } from 'node:child_process' +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' +import { createRuntimeProjectMetadata } from '../src/project-manager.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' +import { + DESKTOP_HOST_PACKAGE, + DESKTOP_HOST_RUNTIME_FILES, + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + readDesktopCorePackageSet, + verifyDesktopCoreLockfile, +} from '../src/core-package-set.ts' +import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { + resolveDesktopAppId, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { + signMacOSRuntime, +} from './macos-runtime.ts' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const BUILD_PATHS = resolveDesktopTargetBuildPaths() +const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh +const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-')) +const STORE_ROOT = join(BUILD_ROOT, 'store') +const RUNTIME_ROOT = BUILD_PATHS.runtime +const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm +const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet +const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') +const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') + +function manifestVersion(path: string, subject: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error(`desktop runtime: ${subject} has no version`) + return manifest.version +} + +function desktopRelease(): DesktopRelease { + const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') + if (version !== dshVersion) { + throw new Error(`desktop runtime: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) + } + const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record + return parseDesktopRelease({ + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: runtime.node, + pnpmVersion: runtime.pnpm, + }) +} + +function runPnpm(args: readonly string[]): Promise { + return new Promise((resolvePromise, reject) => { + const [command, ...commandArgs] = args + if (command === undefined) throw new Error('desktop runtime: pnpm command is required') + const config = join(PNPM_BUILD_STATE, 'config') + const userConfig = join(config, 'npmrc') + mkdirSync(config, { recursive: true }) + writeFileSync(userConfig, '') + const child = spawn(NODE, [ + PNPM, + '--config.registry=https://registry.npmjs.org/', + `--config.store-dir=${STORE_ROOT}`, + '--config.enable-global-virtual-store=false', + `--config.userconfig=${userConfig}`, + command, + ...commandArgs, + ], { + cwd: BUILD_ROOT, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + ))), + NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', + NPM_CONFIG_STORE_DIR: STORE_ROOT, + NPM_CONFIG_USERCONFIG: userConfig, + PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, + XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), + XDG_CONFIG_HOME: config, + XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), + }, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop runtime: pnpm exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function main(): Promise { + rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + mkdirSync(STORE_ROOT, { recursive: true }) + try { + const release = desktopRelease() + copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE)) + cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) + createRuntimeProjectMetadata(BUILD_ROOT, release) + await runPnpm(['install', '--lockfile-only']) + verifyDesktopCoreLockfile( + readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'), + readDesktopCorePackageSet(BUILD_ROOT, release.version), + ) + await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']) + const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) + mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) + cpSync(join(BUILD_ROOT, 'node_modules'), join(DSH_OUTPUT_ROOT, 'node_modules'), { + recursive: true, dereference: true, + filter: source => !source.split(/[\\/]/u).some(part => part === '.bin' || part === '.pnpm' || part === '.modules.yaml' || part === '.pnpm-workspace-state-v1.json'), + }) + writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ + name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', + dependencies: Object.fromEntries(packageSet.packages.map(entry => [entry.name, entry.version])), + }, undefined, 2)}\n`) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', DESKTOP_HOST_PACKAGE, file))) { + throw new Error(`desktop runtime: missing private Host file ${file}`) + } + } + if (process.platform === 'darwin') { + await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) + } + const targetName = resolveDesktopBuildTarget() + const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) + const descriptor = verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) + verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + } catch (error) { + rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) + throw error + } finally { + rmSync(BUILD_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + } +} + +await main() diff --git a/apps/desktop/scripts/prepare-seed.ts b/apps/desktop/scripts/prepare-seed.ts deleted file mode 100644 index 73da078ef2..0000000000 --- a/apps/desktop/scripts/prepare-seed.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** Build the release seed through the same embedded pnpm used on first launch. */ - -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { delimiter, dirname, join, relative, resolve, sep } from 'node:path' -import { createSeedMetadata } from '../src/project-manager.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' -import { - DESKTOP_HOST_PACKAGE, - DESKTOP_HOST_RUNTIME_FILES, - DESKTOP_PACKAGES_DIR, - DESKTOP_PACKAGE_SET_FILE, - readDesktopCorePackageSet, - verifyDesktopCoreLockfile, -} from '../src/core-package-set.ts' -import { - archivePnpmStore, - extractPnpmStoreArchives, - removePnpmProjectRegistrations, -} from '../src/seed-store.ts' -import { - resolveDesktopAppId, - resolveMacOSSigningEnvironment, -} from './desktop-release-environment.mjs' -import { - signMacOSSeedStore, - verifyMacOSSeedStore, -} from './macos-seed-store.ts' -import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' - -const APP_ROOT = resolve(import.meta.dirname, '..') -const BUILD_PATHS = resolveDesktopTargetBuildPaths() -const SEED_OUTPUT_ROOT = BUILD_PATHS.seed -const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-')) -const STORE_ROOT = join(SEED_ROOT, 'store') -const RUNTIME_ROOT = BUILD_PATHS.runtime -const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm -const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet -const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') -const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') - -function manifestVersion(path: string, subject: string): string { - const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } - if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`) - return manifest.version -} - -function desktopRelease(): DesktopRelease { - const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') - const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') - if (version !== dshVersion) { - throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) - } - const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record - return parseDesktopRelease({ - schemaVersion: 1, - version, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - nodeVersion: runtime.node, - pnpmVersion: runtime.pnpm, - }) -} - -function runPnpm(args: readonly string[]): Promise { - return new Promise((resolvePromise, reject) => { - const [command, ...commandArgs] = args - if (command === undefined) throw new Error('desktop seed: pnpm command is required') - const config = join(PNPM_BUILD_STATE, 'config') - const userConfig = join(config, 'npmrc') - mkdirSync(config, { recursive: true }) - writeFileSync(userConfig, '') - const child = spawn(NODE, [ - PNPM, - '--config.registry=https://registry.npmjs.org/', - `--config.store-dir=${STORE_ROOT}`, - '--config.enable-global-virtual-store=false', - `--config.userconfig=${userConfig}`, - command, - ...commandArgs, - ], { - cwd: SEED_ROOT, - env: { - ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) - ))), - NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', - NPM_CONFIG_STORE_DIR: STORE_ROOT, - NPM_CONFIG_USERCONFIG: userConfig, - PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, - XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), - XDG_CONFIG_HOME: config, - XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), - }, - stdio: 'inherit', - }) - child.once('error', reject) - child.once('close', (code, signal) => { - if (code === 0) resolvePromise() - else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`)) - }) - }) -} - -function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] { - const files: string[] = [] - const visit = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) visit(path) - else if (entry.isFile()) files.push(path) - else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`) - } - } - visit(root) - return files.sort().map((path) => { - const body = readFileSync(path) - return { - path: relative(root, path).split(sep).join('/'), - bytes: statSync(path).size, - sha256: createHash('sha256').update(body).digest('hex'), - } - }) -} - -async function verifyOfflineInstallation(release: DesktopRelease): Promise { - const installedModules = join(SEED_ROOT, 'node_modules') - try { - await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/')) - for (const file of DESKTOP_HOST_RUNTIME_FILES) { - if (!existsSync(join(hostRoot, file))) { - throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`) - } - } - } finally { - rmSync(installedModules, { recursive: true, force: true }) - } -} - -async function main(): Promise { - rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true }) - rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) - mkdirSync(STORE_ROOT, { recursive: true }) - try { - const release = desktopRelease() - copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE)) - cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) - createSeedMetadata(SEED_ROOT, release) - await runPnpm(['install', '--lockfile-only']) - verifyDesktopCoreLockfile( - readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'), - readDesktopCorePackageSet(SEED_ROOT, release.version), - ) - const installedModules = join(SEED_ROOT, 'node_modules') - await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts']) - rmSync(installedModules, { recursive: true, force: true }) - rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) - await verifyOfflineInstallation(release) - const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform - let signedMachOFiles: number | undefined - let macOSSigning: ReturnType | undefined - if (targetPlatform === 'darwin') { - macOSSigning = resolveMacOSSigningEnvironment(process.env) - const signing = await signMacOSSeedStore( - STORE_ROOT, - resolveDesktopAppId(process.env), - macOSSigning, - ) - signedMachOFiles = signing.signedFiles - process.stdout.write( - `desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`, - ) - await verifyOfflineInstallation(release) - } - removePnpmProjectRegistrations(STORE_ROOT) - archivePnpmStore(SEED_ROOT, STORE_ROOT) - if (macOSSigning !== undefined && signedMachOFiles !== undefined) { - const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-')) - try { - extractPnpmStoreArchives(SEED_ROOT, extractedStore) - const verified = verifyMacOSSeedStore(extractedStore, macOSSigning) - if (verified !== signedMachOFiles) { - throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`) - } - } finally { - rmSync(extractedStore, { recursive: true, force: true }) - } - } - const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json') - writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`) - cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true }) - } finally { - rmSync(SEED_ROOT, { recursive: true, force: true }) - } -} - -await main() diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts new file mode 100644 index 0000000000..4b4103e534 --- /dev/null +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -0,0 +1,58 @@ +/** Boot the materialized target runtime without access to a user's Harness profile. */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DesktopHostProcess } from '../src/host-process.ts' +import { createPluginProfile } from '../src/project-manager.ts' +import { linkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' + +/** + * Prove the final resource tree boots and serves its matching Web frontend. + * @param root - Materialized dsh resources. + * @param node - Prepared target Node executable. + * @param runtime - Verified resource descriptor. + */ +export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise { + const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) + const profile = join(home, 'profiles', 'desktop') + const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home }) + try { + createPluginProfile(profile) + const pluginName = 'desktop-runtime-smoke-plugin' + const plugin = join(profile, 'node_modules', pluginName) + mkdirSync(plugin, { recursive: true }) + const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis') + if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package') + writeFileSync(join(plugin, 'package.json'), JSON.stringify({ + name: pluginName, version: '1.0.0', type: 'module', exports: './index.js', + peerDependencies: { '@deepseek-ai/cordis': cordis.version }, dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(plugin, 'index.js'), ` +import { Context } from '@deepseek-ai/cordis' +export function apply(ctx) { + if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') +} +`) + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n') + const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { + dependencies: Record + dsh: { profile: { bundles: string[] } } + } + manifest.dependencies[pluginName] = '1.0.0' + manifest.dsh.profile.bundles.push(pluginName) + writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) + linkDesktopHostPackages(profile, root, runtime) + validateDesktopPluginGraph(profile, root, runtime, [pluginName]) + const ready = await host.start() + if (ready.dshVersion !== runtime.release.version) throw new Error('desktop runtime: Host reported another dsh release') + const response = await host.fetch(new Request('dsh-app://app/')) + if (response.status !== 200 || !(await response.text()).includes(' /** - * Verify one Mach-O file embedded in the seed store. + * Verify one Mach-O file embedded in the runtime tree. * @param path - Mach-O file to inspect. * @param expected - Public release identity. */ -export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void +export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvironment): void /** * Verify the full application signature and its release owner. diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 48b254dedd..af0d6196ae 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -1,4 +1,4 @@ -/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */ +/** Sign runtime code and verify that packaged macOS artifacts carry the company release identity. */ import { spawn, spawnSync } from 'node:child_process' import { resolve } from 'node:path' @@ -21,19 +21,19 @@ export function assertMacOSSignatureDetails(details, expected) { } /** - * Require the signature properties Apple validates for executable seed content. + * Require the signature properties Apple validates for executable runtime content. * @param {string} details - Output from `codesign --display --verbose=4`. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {void} */ -export function assertMacOSSeedSignatureDetails(details, expected) { +export function assertMacOSRuntimeSignatureDetails(details, expected) { assertMacOSSignatureDetails(details, expected) const fields = details.split(/\r?\n/u).map(line => line.trim()) if (!fields.some(line => /^Timestamp=.+/u.test(line))) { - throw new Error('desktop macOS signing: seed signature has no secure timestamp') + throw new Error('desktop macOS signing: runtime signature has no secure timestamp') } if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) { - throw new Error('desktop macOS signing: seed signature does not enable hardened runtime') + throw new Error('desktop macOS signing: runtime signature does not enable hardened runtime') } } @@ -60,7 +60,7 @@ function runAppleCommand(command, args, label) { } /** - * Execute one Apple release tool without blocking other independent seed signers. + * Execute one Apple release tool without blocking other independent runtime signers. * @param {string} command - Absolute executable path. * @param {readonly string[]} args - Tool arguments. * @param {string} label - Stable diagnostic name. @@ -106,13 +106,13 @@ function runCodeSign(args) { } /** - * Sign one Mach-O file embedded in the seed store. + * Sign one Mach-O file embedded in the runtime tree. * @param {string} path - Writable standalone Mach-O file. * @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {Promise} Resolves after codesign exits successfully. */ -export async function signMacOSSeedCode(path, identifier, expected) { +export async function signMacOSRuntimeCode(path, identifier, expected) { await runAppleCommandAsync('/usr/bin/codesign', [ '--force', '--sign', expected.signingIdentity, @@ -124,15 +124,15 @@ export async function signMacOSSeedCode(path, identifier, expected) { } /** - * Verify one Mach-O file embedded in the seed store. + * Verify one Mach-O file embedded in the runtime tree. * @param {string} path - Mach-O file to inspect. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {void} */ -export function verifyMacOSSeedCode(path, expected) { +export function verifyMacOSRuntimeCode(path, expected) { runCodeSign(['--verify', '--strict', '--verbose=2', path]) const details = runCodeSign(['--display', '--verbose=4', path]) - assertMacOSSeedSignatureDetails(details, expected) + assertMacOSRuntimeSignatureDetails(details, expected) } /** diff --git a/apps/desktop/src/core-package-set.ts b/apps/desktop/src/core-package-set.ts index ff93bd5605..249057d647 100644 --- a/apps/desktop/src/core-package-set.ts +++ b/apps/desktop/src/core-package-set.ts @@ -124,7 +124,7 @@ export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string /** * Verify every local tarball and reject extra package files before pnpm executes them. - * @param projectDir - Seed or profile directory containing the package set. + * @param projectDir - Build directory containing the package set. * @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron. * @returns The verified package set. */ diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index ed18d0c7d6..20c59cf7e1 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -86,28 +86,33 @@ export class DesktopHostProcess { /** * @param node - absolute bundled upstream Node.js executable. - * @param projectDir - active or staged desktop npm project. + * @param runtimeDir - immutable packages carried by the current application. + * @param projectDir - active or staged desktop plugin profile. * @param inspectPort - optional loopback inspector port for workspace development. + * @param environment - Child environment; runtime and package-manager overrides are removed. */ constructor( private readonly node: string, + private readonly runtimeDir: string, private readonly projectDir: string, private readonly inspectPort?: number, + private readonly environment: NodeJS.ProcessEnv = process.env, ) {} /** Start the child once and resolve only after its complete composition is active. */ async start(): Promise { if (this.child !== undefined) return this.readyPromise - const entry = join(this.projectDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') + const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') const child = spawn(this.node, [ ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]), entry, + this.runtimeDir, this.projectDir, ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']), ], { cwd: this.projectDir, - env: Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - name !== 'NODE_OPTIONS' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + env: Object.fromEntries(Object.entries(this.environment).filter(([name]) => ( + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))), stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'], }) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 4fb946a2a5..f98bf22902 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -10,6 +10,10 @@ export const DESKTOP_IPC = { pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', pluginsUpdate: 'dsh-desktop:plugins-update', + pluginsToggle: 'dsh-desktop:plugins-toggle', + pluginsDisableAll: 'dsh-desktop:plugins-disable-all', + backendStatus: 'dsh-desktop:backend-status', + backendRetry: 'dsh-desktop:backend-retry', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', updatesState: 'dsh-desktop:updates-state', @@ -31,6 +35,12 @@ export interface DshDesktopApi { add(spec: string): Promise remove(name: string): Promise update(name: string, version: string): Promise + toggle(name: string, enabled: boolean): Promise + disableAll(): Promise + } + readonly backend: { + status(): Promise<{ readonly ready: boolean; readonly error?: string }> + retry(): Promise } readonly updates: { check(): Promise diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index b666cfd559..65e5083313 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -20,6 +20,13 @@ export const en = { pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins', pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.', refresh: 'Refresh', + enable: 'Enable', + disable: 'Disable', + disabled: 'Disabled', + retry: 'Retry startup', + disableAll: 'Disable all plugins and retry', + recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.', + changingActivation: 'Changing plugin activation…', npmPackage: 'npm package', install: 'Install', installed: 'Installed', @@ -59,6 +66,13 @@ export const zh = { pluginWindowTitle: 'DeepSeek Harness — 桌面插件', pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', refresh: '刷新', + enable: '启用', + disable: '禁用', + disabled: '已禁用', + retry: '重试启动', + disableAll: '禁用全部插件并重试', + recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。', + changingActivation: '正在更改插件启用状态…', npmPackage: 'npm 包', install: '安装', installed: '已安装', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8bc9fdcefd..a995d6818e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -49,7 +49,7 @@ const MIME: Readonly> = { interface RuntimeResources { readonly node: string readonly pnpm: string - readonly seed: string + readonly dsh: string } function runtimeResources(): RuntimeResources { @@ -58,8 +58,8 @@ function runtimeResources(): RuntimeResources { ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node') const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') - const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : undefined) ?? join(process.resourcesPath, 'seed') - return { node, pnpm, seed } + const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? join(process.resourcesPath, 'dsh') + return { node, pnpm, dsh } } function developmentProject(): string | undefined { @@ -137,8 +137,8 @@ async function main(): Promise { const activeProject = development ?? paths.profile const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - if (development === undefined) manager.recover() let host: DesktopHostProcess | undefined + let startupError: string | undefined let mainWindow: BrowserWindow | undefined let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false @@ -157,9 +157,15 @@ async function main(): Promise { } const startHost = async (projectDir = activeProject): Promise => { - const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort) - await next.start() - return next + if (development === undefined) manager.assertProfileRuntime(projectDir) + const next = new DesktopHostProcess(resources.node, development ?? resources.dsh, projectDir, hostInspectPort) + try { + await next.start() + return next + } catch (error) { + await next.stop() + throw error + } } const hooks: DesktopProjectHooks = { healthCheck: async (projectDir) => { @@ -202,14 +208,18 @@ async function main(): Promise { }, } - if (development === undefined) { - await manager.applyRelease(resources.seed, app.getVersion(), { - ...hooks, - beforeActivate: async () => {}, - afterActivate: async () => {}, - }) + const reconcileBackend = async (): Promise => { + try { + if (development === undefined) { + await manager.applyRelease(app.getVersion(), { ...hooks, beforeActivate: async () => {}, afterActivate: async () => {} }) + } + host = await startHost() + startupError = undefined + } catch (error) { + startupError = errorOf(error, messages.startupFailed).message + throw error + } } - host = await startHost() const updates = new DesktopUpdateCoordinator( publishUpdate, @@ -236,7 +246,9 @@ async function main(): Promise { throw new Error('dsh desktop: plugin package changes require a packaged application') } await manager.mutate(mutation, hooks) + startupError = undefined if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() + else focusPrimaryWindow() } ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { assertDesktopSender(event, ['shell']) @@ -261,6 +273,20 @@ async function main(): Promise { } return mutate(event, { type: 'plugin-update', name, version }) }) + ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => { + if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request') + return mutate(event, { type: 'plugin-toggle', name, enabled }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' })) + ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => { + assertDesktopSender(event, ['shell']) + return { ready: host !== undefined, ...(startupError === undefined ? {} : { error: startupError }) } + }) + ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => { + assertDesktopSender(event, ['shell']) + if (host === undefined) await reconcileBackend() + focusPrimaryWindow() + }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { assertDesktopSender(event, ['shell']) return updates.check() @@ -348,6 +374,7 @@ async function main(): Promise { return window } focusPrimaryWindow = () => { + if (host === undefined) { openPluginWindow(); return } const window = mainWindow if (window === undefined || window.isDestroyed()) { const replacement = createMainWindow() @@ -359,9 +386,13 @@ async function main(): Promise { window.focus() } - mainWindow = createMainWindow() - await mainWindow.loadURL(`${SCHEME}://app/index.html`) - if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + await reconcileBackend().catch(() => undefined) + if (host === undefined) openPluginWindow() + else { + mainWindow = createMainWindow() + await mainWindow.loadURL(`${SCHEME}://app/index.html`) + } + if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { mainWindow.webContents.openDevTools({ mode: 'detach' }) } publishUpdate(updateState) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2fc4dc8ee0..4ad4243bdb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,8 +10,14 @@ const api: DshDesktopApi = { list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, + toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise, + disableAll: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, }, + backend: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, + retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + }, updates: { check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts new file mode 100644 index 0000000000..6326747b43 --- /dev/null +++ b/apps/desktop/src/profile-packages.ts @@ -0,0 +1,254 @@ +/** Desktop-owned host links and validation of the external plugin dependency graph. */ + +import { createHash } from 'node:crypto' +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { satisfies } from 'semver' +import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts' + +/** Applied runtime identity and the only links Desktop may replace. */ +export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json' + +/** Durable ownership of a package-directory link. */ +export interface DesktopPackageLink { + readonly name: string + readonly target: string +} + +/** Facts from the last successfully validated profile. */ +export interface DesktopProfileState { + readonly schemaVersion: 1 + readonly runtimeId: string + readonly version: string + readonly nodeVersion: string + readonly platform: string + readonly arch: string + readonly lockHash: string + readonly links: readonly DesktopPackageLink[] +} + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stat(path: string): ReturnType | undefined { + try { return lstatSync(path) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return undefined + } +} + +function inside(root: string, path: string): boolean { + const child = relative(root, path) + return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`)) +} + +/** + * Read profile state without interpreting an unpublished predecessor format. + * @param profile - Desktop profile directory. + * @returns Validated state, or undefined for an uninitialized profile. + */ +export function readDesktopProfileState(profile: string): DesktopProfileState | undefined { + const path = join(profile, DESKTOP_PROFILE_STATE) + if (!existsSync(path)) return undefined + const value: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!record(value) || value.schemaVersion !== 1 || typeof value.runtimeId !== 'string' + || !/^[a-f0-9]{64}$/u.test(value.runtimeId) || typeof value.version !== 'string' + || typeof value.nodeVersion !== 'string' || typeof value.platform !== 'string' || typeof value.arch !== 'string' + || typeof value.lockHash !== 'string' || !Array.isArray(value.links)) { + throw new Error('desktop profile: invalid runtime state') + } + const links = value.links.map((link: unknown): DesktopPackageLink => { + if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name) + || typeof link.target !== 'string' || !isAbsolute(link.target)) { + throw new Error('desktop profile: invalid managed link') + } + return { name: link.name, target: link.target } + }) + if (new Set(links.map(link => link.name)).size !== links.length) throw new Error('desktop profile: duplicate managed link') + return { schemaVersion: 1, runtimeId: value.runtimeId, version: value.version, nodeVersion: value.nodeVersion, + platform: value.platform, arch: value.arch, lockHash: value.lockHash, links } +} + +/** + * Hash the plugin lockfile, including the empty-profile case. + * @param profile - Desktop profile directory. + * @returns Lockfile content identity. + */ +export function desktopPluginLockHash(profile: string): string { + const lock = join(profile, 'pnpm-lock.yaml') + return createHash('sha256').update(existsSync(lock) ? readFileSync(lock) : '').digest('hex') +} + +/** + * Remove only recorded host links, without following even broken targets. + * @param profile - Active or copied profile. + */ +export function unlinkDesktopHostPackages(profile: string): void { + for (const link of readDesktopProfileState(profile)?.links ?? []) { + const path = join(profile, 'node_modules', link.name) + const entry = stat(path) + if (entry === undefined) continue + if (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target)) { + throw new Error(`desktop profile: refusing to replace unowned package ${link.name}`) + } + unlinkSync(path) + } +} + +/** + * Copy plugin files without copying host packages or sharing writable hardlinks. + * @param source - Active Desktop profile. + * @param target - Empty transaction profile directory. + */ +export function copyDesktopProfile(source: string, target: string): void { + const owned = readDesktopProfileState(source)?.links ?? [] + for (const link of owned) { + const path = join(source, 'node_modules', link.name) + const entry = stat(path) + if (entry !== undefined && (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target))) { + throw new Error(`desktop profile: refusing to copy unowned package ${link.name}`) + } + } + const links = new Set(owned.map(link => join(source, 'node_modules', link.name))) + cpSync(source, target, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true, + filter: path => !links.has(path) }) +} + +/** + * Bind an external profile to this application's real package directories. + * @param profile - Candidate profile. + * @param root - Current immutable runtime directory. + * @param runtime - Verified release descriptor. + */ +export function linkDesktopHostPackages(profile: string, root: string, runtime: DesktopRuntimeDescriptor): void { + unlinkDesktopHostPackages(profile) + const links = runtime.sharedPackages.map(entry => ({ name: entry.name, target: runtimePath(root, entry.path) })) + for (const link of links) { + const path = join(profile, 'node_modules', link.name) + if (stat(path) !== undefined) throw new Error(`desktop profile: plugin installed reserved host package ${link.name}`) + mkdirSync(dirname(path), { recursive: true }) + symlinkSync(link.target, path, process.platform === 'win32' ? 'junction' : 'dir') + } + const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version, + nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch, + lockHash: desktopPluginLockHash(profile), links } + writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 }) +} + +interface PackageManifest { + readonly name: string + readonly version: string + readonly dependencies: Readonly> + readonly optionalDependencies: Readonly> + readonly peerDependencies: Readonly> + readonly optionalPeers: ReadonlySet +} + +function manifest(path: string): PackageManifest { + const value: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) + if (!record(value) || typeof value.name !== 'string' || typeof value.version !== 'string') { + throw new Error(`desktop profile: invalid package manifest ${path}`) + } + const dependencies = (key: string): Record => { + const field = value[key] + if (field === undefined) return {} + if (!record(field) || Object.entries(field).some(([name, spec]) => !PACKAGE_NAME.test(name) || typeof spec !== 'string')) { + throw new Error(`desktop profile: invalid ${key} in ${path}`) + } + return field as Record + } + const optionalPeers = new Set() + if (record(value.peerDependenciesMeta)) { + for (const [name, meta] of Object.entries(value.peerDependenciesMeta)) { + if (record(meta) && meta.optional === true) optionalPeers.add(name) + } + } + return { name: value.name, version: value.version, dependencies: dependencies('dependencies'), + optionalDependencies: dependencies('optionalDependencies'), peerDependencies: dependencies('peerDependencies'), optionalPeers } +} + +function packageFrom(anchor: string, name: string): string | undefined { + if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`) + for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) { + const path = join(modules, name) + if (existsSync(join(path, 'package.json'))) return realpathSync(path) + } + return undefined +} + +/** + * Prove active plugin dependencies stay local and share the host's exact package instances. + * @param profile - Profile with its generated host links present. + * @param root - Immutable runtime directory. + * @param runtime - Verified shared package inventory. + * @param activePlugins - Explicit enabled plugin roots whose peer compatibility is required. + */ +export function validateDesktopPluginGraph( + profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], +): void { + const profileRoot = realpathSync(profile) + const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync(runtimePath(root, entry.path))])) + for (const [name, path] of shared) { + if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) + } + const scanned = new Set() + const scan = (modules: string): void => { + if (!existsSync(modules)) return + if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`) + const directory = realpathSync(modules) + if (scanned.has(directory)) return + scanned.add(directory) + for (const entry of readdirSync(modules, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue + const path = join(modules, entry.name) + if (entry.name.startsWith('@')) { scan(path); continue } + if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`) + const canonical = realpathSync(path) + const info = manifest(canonical) + const host = shared.get(info.name) + if (host !== undefined) { + if (canonical !== host || path !== join(profile, 'node_modules', info.name)) { + throw new Error(`desktop profile: duplicate or aliased host package ${info.name} at ${path}`) + } + continue + } + if (entry.isSymbolicLink()) throw new Error(`desktop profile: linked private package ${path}`) + if (!inside(profileRoot, canonical)) throw new Error(`desktop profile: package resolves outside profile: ${path}`) + scan(join(path, 'node_modules')) + } + } + scan(join(profile, 'node_modules')) + const visited = new Set() + const visit = (path: string, chain: string): void => { + if (visited.has(path)) return + visited.add(path) + const info = manifest(path) + const deps = { ...info.dependencies, ...info.optionalDependencies } + for (const [name, range] of Object.entries({ ...deps, ...info.peerDependencies })) { + const peer = name in info.peerDependencies + const optional = peer ? info.optionalPeers.has(name) : name in info.optionalDependencies + const target = packageFrom(path, name) + if (target === undefined && optional) continue + if (target === undefined) throw new Error(`desktop profile: ${chain} requires missing ${name}@${range}`) + const host = shared.get(name) + if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`) + if (host !== undefined ? target !== host : !inside(profileRoot, target)) { + throw new Error(`desktop profile: ${chain} resolves ${name} outside its owned packages`) + } + const dependency = manifest(target) + if (peer && !satisfies(dependency.version, range)) { + throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${dependency.version}`) + } + if (host === undefined) visit(target, `${chain} -> ${name}`) + } + } + for (const name of activePlugins) { + const path = packageFrom(profile, name) + if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) + visit(path, name) + } +} diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index b9b94f7ac8..89c9e86c73 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,11 +1,9 @@ /** Transactional owner of the reserved desktop profile and its private pnpm state. */ +import { valid } from 'semver' import { spawn } from 'node:child_process' -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { - constants, - copyFileSync, - cpSync, existsSync, fsyncSync, ftruncateSync, @@ -13,7 +11,6 @@ import { mkdirSync, openSync, closeSync, - readdirSync, readFileSync, renameSync, rmSync, @@ -23,31 +20,23 @@ import { } from 'node:fs' import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { - DESKTOP_PACKAGES_DIR, - DESKTOP_PACKAGE_SET_FILE, DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, - desktopDshPackageSpec, - readDesktopCorePackageSet, verifyDesktopCorePackageSet, } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' -import { parseDesktopRelease, type DesktopRelease } from './release.ts' -import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts' - -/** Files the package transaction copies between active and staging projects. */ -const DESKTOP_PROJECT_FILES = [ - 'package.json', - 'pnpm-lock.yaml', - 'pnpm-workspace.yaml', - 'desktop-release.json', - DESKTOP_PACKAGE_SET_FILE, -] as const +import type { DesktopRelease } from './release.ts' +import { desktopRuntimeId, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { + copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, + unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, +} from './profile-packages.ts' /** Desktop plugin record derived from the installed profile. */ export interface DesktopPluginRecord { readonly name: string readonly version: string + readonly enabled: boolean } /** Installed desktop project manifest slice. */ @@ -68,6 +57,8 @@ interface DesktopPendingTransaction { readonly schemaVersion: 1 readonly id: string readonly stagingProfile: string + readonly fromRuntimeId: string | null + readonly toRuntimeId: string readonly step: 'prepared' | 'active-moved' | 'staging-activated' } @@ -75,6 +66,7 @@ interface DesktopPendingTransaction { export interface DesktopRuntimeExecutables { readonly node: string readonly pnpm: string + readonly dsh: string } /** Hooks that bind project replacement to backend lifecycle and health. */ @@ -92,12 +84,8 @@ export type DesktopProjectMutation = | { readonly type: 'plugin-add'; readonly spec: string } | { readonly type: 'plugin-remove'; readonly name: string } | { readonly type: 'plugin-update'; readonly name: string; readonly version: string } - -interface DesktopSeedIntegrityRecord { - readonly path: string - readonly bytes: number - readonly sha256: string -} + | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean } + | { readonly type: 'plugins-disable-all' } const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' const DSH_PACKAGE = '@deepseek-ai/dsh' @@ -133,10 +121,6 @@ function workspaceFile(overrides: Readonly> = {}): string return `packages:\n - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n ${JSON.stringify(coreBuildKey)}: true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n` } -function releaseFile(projectDir: string): DesktopRelease { - return parseDesktopRelease(readJson(join(projectDir, 'desktop-release.json'))) -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -190,83 +174,20 @@ function removeOwnedDirectory(path: string): void { rmSync(path, { recursive: true }) } -function copyMetadata(source: string, target: string): void { - mkdirSync(target, { recursive: true, mode: 0o700 }) - for (const filename of DESKTOP_PROJECT_FILES) { - const from = join(source, filename) - if (existsSync(from)) copyFileSync(from, join(target, filename), constants.COPYFILE_EXCL) - } - cpSync(join(source, DESKTOP_PACKAGES_DIR), join(target, DESKTOP_PACKAGES_DIR), { - recursive: true, - force: false, - errorOnExist: true, - }) -} - -function seedFiles(root: string): readonly DesktopSeedIntegrityRecord[] { - const files: DesktopSeedIntegrityRecord[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - const relativePath = path.slice(root.length + 1).split(sep).join('/') - if (relativePath === 'integrity.json') continue - if (entry.isSymbolicLink()) throw new Error(`desktop seed: symbolic link is not allowed: ${relativePath}`) - if (entry.isDirectory()) { - visit(path) - continue - } - if (!entry.isFile()) throw new Error(`desktop seed: unsupported file type: ${relativePath}`) - const body = readFileSync(path) - files.push({ - path: relativePath, - bytes: body.byteLength, - sha256: createHash('sha256').update(body).digest('hex'), - }) - } - } - visit(root) - return files.sort((left, right) => left.path.localeCompare(right.path)) -} - -/** Verify the packaged offline seed before any content enters writable desktop state. */ -export function verifySeedIntegrity(seedDir: string): void { - const integrityPath = join(seedDir, 'integrity.json') - const integrity = readJson(integrityPath) - if (!isRecord(integrity) || integrity.schemaVersion !== 2 || !Array.isArray(integrity.files)) { - throw new Error(`desktop seed: invalid integrity inventory ${integrityPath}`) - } - const expected: DesktopSeedIntegrityRecord[] = integrity.files.map((record) => { - if (!isRecord(record) || typeof record.path !== 'string' || record.path === '' || record.path.startsWith('/') - || record.path.split('/').includes('..') || typeof record.bytes !== 'number' - || !Number.isSafeInteger(record.bytes) || record.bytes < 0 - || typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) { - throw new Error(`desktop seed: invalid integrity record in ${integrityPath}`) - } - return { path: record.path, bytes: record.bytes, sha256: record.sha256 } - }).sort((left, right) => left.path.localeCompare(right.path)) - const actual = seedFiles(seedDir) - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error('desktop seed: integrity verification failed') - } -} - function projectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined const profile = isRecord(dsh?.profile) ? dsh.profile : undefined if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true - || typeof value.version !== 'string' || !isRecord(value.dependencies) + || typeof value.version !== 'string' || (value.dependencies !== undefined && !isRecord(value.dependencies)) || !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) { throw new Error(`desktop project: invalid desktop profile manifest ${path}`) } - const manifest = value as unknown as DesktopProjectManifest - const packageSet = readDesktopCorePackageSet(projectDir, releaseFile(projectDir).version) - const expectedOverrides = desktopCorePackageOverrides(packageSet) - if (manifest.dependencies[DSH_PACKAGE] !== desktopDshPackageSpec(packageSet) - || Object.entries(expectedOverrides).some(([name, spec]) => manifest.dependencies[name] !== spec) - || readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf8') !== workspaceFile(expectedOverrides)) { - throw new Error(`desktop project: core package mapping does not match ${DESKTOP_PACKAGE_SET_FILE}`) + const manifest = { ...value, dependencies: value.dependencies ?? {} } as unknown as DesktopProjectManifest + if (Object.entries(manifest.dependencies).some(([name, version]) => !PACKAGE_NAME_PATTERN.test(name) + || typeof version !== 'string' || valid(version) !== version)) { + throw new Error('desktop project: plugin dependencies must use exact registry versions') } return manifest } @@ -285,7 +206,7 @@ function profilePluginNames(projectDir: string): readonly string[] { } function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] { - return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name)) + return Object.keys(projectManifest(projectDir).dependencies).sort().map(name => inspectPlugin(projectDir, name)) } function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void { @@ -296,7 +217,7 @@ function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPlugin ...manifest.dsh, profile: { ...manifest.dsh.profile, - bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)], + bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.filter(plugin => plugin.enabled).map(plugin => plugin.name)], }, }, } satisfies DesktopProjectManifest) @@ -322,12 +243,13 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) { throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`) } - return { name: requestedName, version: manifest.version } + return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) } } /** Transactional desktop npm project manager. */ export class DesktopProjectManager { private lockDescriptor: number | undefined + private descriptor: DesktopRuntimeDescriptor | undefined /** * @param paths - Electron-owned package state and reserved desktop profile paths. @@ -345,6 +267,9 @@ export class DesktopProjectManager { if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string' || !isDescendant(this.paths.staging, value.stagingProfile) + || value.stagingProfile !== join(this.paths.staging, value.id, 'profile') + || (value.fromRuntimeId !== null && (typeof value.fromRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fromRuntimeId))) + || typeof value.toRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.toRuntimeId) || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) { throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`) } @@ -352,9 +277,20 @@ export class DesktopProjectManager { schemaVersion: 1, id: value.id, stagingProfile: value.stagingProfile, + fromRuntimeId: value.fromRuntimeId, + toRuntimeId: value.toRuntimeId, step: value.step, } - if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) { + if (pending.step === 'staging-activated' && !existsSync(pending.stagingProfile) && existsSync(this.paths.profile)) { + if (readDesktopProfileState(this.paths.profile)?.runtimeId !== pending.toRuntimeId) { + throw new Error('desktop project: activated profile does not match its journal') + } + removeOwnedDirectory(this.paths.profile) + } + if (!existsSync(this.paths.profile) && pending.fromRuntimeId !== null && existsSync(this.paths.rollback)) { + if (readDesktopProfileState(this.paths.rollback)?.runtimeId !== pending.fromRuntimeId) { + throw new Error('desktop project: rollback profile does not match its journal') + } mkdirSync(dirname(this.paths.profile), { recursive: true }) renameSync(this.paths.rollback, this.paths.profile) } @@ -368,64 +304,59 @@ export class DesktopProjectManager { return pluginRecords(this.paths.profile) } - /** Read the exact dsh version installed in the active desktop project. */ + /** Read the dsh version supplied by this application's verified resources. */ dshVersion(): string { - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - return this.installedPackageVersion(DSH_PACKAGE) + return this.currentRuntime().release.version } - private installedPackageVersion(packageName: string): string { - const manifestPath = join(this.paths.profile, 'node_modules', ...packageName.split('/'), 'package.json') - const manifest = readJson(manifestPath) - if (!isRecord(manifest) || typeof manifest.version !== 'string') { - throw new Error(`desktop project: installed ${packageName} package has no version`) - } - assertVersion(manifest.version) - return manifest.version - } - - /** Read the release version applied to the active desktop project. */ + /** Read the release most recently applied to the active profile. */ releaseVersion(): string { - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - return releaseFile(this.paths.profile).version + const state = readDesktopProfileState(this.paths.profile) + if (state === undefined) throw new Error('desktop project: active profile has no runtime state') + return state.version } - /** Install or reconcile the active project to the Electron package's exact release. */ - async applyRelease(seedDir: string, electronVersion: string, hooks: DesktopProjectHooks): Promise { + /** Reject a profile whose dependency links were prepared for another runtime. */ + assertProfileRuntime(projectDir: string): void { + if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) { + throw new Error('desktop project: profile does not match this application runtime') + } + } + + private currentRuntime(): DesktopRuntimeDescriptor { + if (this.descriptor === undefined) throw new Error('desktop project: runtime has not been verified') + return this.descriptor + } + + private prepareProfile(projectDir: string): void { + const runtime = this.currentRuntime() + linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime) + validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir)) + } + + /** Verify this release and reconcile its external profile without installing core packages. */ + async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise { return this.withLock(async () => { this.recover() - verifySeedIntegrity(seedDir) - const target = releaseFile(seedDir) - verifyDesktopCorePackageSet(seedDir, target.version) - if (target.version !== electronVersion) { - throw new Error(`desktop project: seed ${target.version} does not match Electron ${electronVersion}`) + const target = verifyDesktopRuntime(this.runtime.dsh, electronVersion) + this.descriptor = target + const previous = readDesktopProfileState(this.paths.profile) + if (existsSync(this.paths.profile) && previous === undefined) { + throw new Error('desktop project: existing profile is not a Desktop plugin profile') } - if (existsSync(this.paths.profile) && this.releaseVersion() === target.version - && this.dshVersion() === target.version - && this.installedPackageVersion(DESKTOP_HOST_PACKAGE) === target.version) { - verifyDesktopCorePackageSet(this.paths.profile, target.version) + if (previous?.runtimeId === desktopRuntimeId(target) + && previous.lockHash === desktopPluginLockHash(this.paths.profile) + && previous.links.length === target.sharedPackages.length + && previous.links.every(link => link.target === join(this.runtime.dsh, 'node_modules', link.name) + && existsSync(join(this.paths.profile, 'node_modules', link.name)))) { + validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } - this.mergeSeedPnpmState(seedDir) const stagingProfile = this.newStagingProfile() try { - if (existsSync(this.paths.profile)) { - const plugins = pluginRecords(this.paths.profile) - copyMetadata(seedDir, stagingProfile) - await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - if (plugins.length > 0) { - await this.runPnpm(stagingProfile, [ - 'add', - ...plugins.map(plugin => `${plugin.name}@${plugin.version}`), - '--save-exact', - '--offline', - ]) - writeProfilePlugins(stagingProfile, plugins) - } - } else { - copyMetadata(seedDir, stagingProfile) - await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - } + if (previous === undefined) createPluginProfile(stagingProfile) + else copyDesktopProfile(this.paths.profile, stagingProfile) + await this.reconcileProfile(stagingProfile, previous) await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) return true @@ -436,16 +367,18 @@ export class DesktopProjectManager { }) } - /** Apply one exact dependency mutation through a staging project. */ + /** Apply an exact plugin dependency or activation change through a staging project. */ async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { await this.withLock(async () => { this.recover() + this.currentRuntime() if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - verifyDesktopCorePackageSet(this.paths.profile, this.releaseVersion()) const stagingProfile = this.newStagingProfile() try { - copyMetadata(this.paths.profile, stagingProfile) + copyDesktopProfile(this.paths.profile, stagingProfile) await this.applyMutation(stagingProfile, mutation) + await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile), + mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all') await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) } catch (error) { @@ -455,6 +388,25 @@ export class DesktopProjectManager { }) } + private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise { + const target = this.currentRuntime() + this.prepareProfile(projectDir) + const rebuild = previous !== undefined && pluginRecords(projectDir).length > 0 + && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch) + if (rebuild) { + unlinkDesktopHostPackages(projectDir) + removeOwnedDirectory(join(projectDir, 'node_modules')) + await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts']) + } + if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir) + } + + private async finishPackageOperation(projectDir: string): Promise { + this.prepareProfile(projectDir) + await this.runPnpm(projectDir, ['rebuild', '--pending']) + this.prepareProfile(projectDir) + } + private newStagingProfile(): string { const path = join(this.paths.staging, randomUUID(), 'profile') mkdirSync(path, { recursive: true, mode: 0o700 }) @@ -466,8 +418,11 @@ export class DesktopProjectManager { case 'plugin-add': { const requestedName = packageNameFromSpec(mutation.spec) if (requestedName === undefined) throw new Error('desktop project: plugin package name is required') - await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact']) - const installed = inspectPlugin(projectDir, requestedName) + if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) { + throw new Error(`desktop project: cannot install host-owned package ${requestedName}`) + } + await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact', '--ignore-scripts']) + const installed = { ...inspectPlugin(projectDir, requestedName), enabled: true } const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name) writeProfilePlugins( projectDir, @@ -477,21 +432,21 @@ export class DesktopProjectManager { } case 'plugin-remove': { assertPackageName(mutation.name) - if (!profilePluginNames(projectDir).includes(mutation.name)) { + if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) { throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) } const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name) - await this.runPnpm(projectDir, ['remove', mutation.name]) + await this.runPnpm(projectDir, ['remove', mutation.name, '--config.ignore-scripts=true']) writeProfilePlugins(projectDir, remaining) return } case 'plugin-update': assertPackageName(mutation.name) assertVersion(mutation.version) - if (!profilePluginNames(projectDir).includes(mutation.name)) { + if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) { throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) } - await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact']) + await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact', '--ignore-scripts']) { const installed = inspectPlugin(projectDir, mutation.name) writeProfilePlugins( @@ -500,33 +455,37 @@ export class DesktopProjectManager { ) } return + case 'plugins-disable-all': + writeProfilePlugins(projectDir, pluginRecords(projectDir).map(plugin => ({ ...plugin, enabled: false }))) + return + case 'plugin-toggle': { + assertPackageName(mutation.name) + const plugins = pluginRecords(projectDir) + if (!plugins.some(plugin => plugin.name === mutation.name)) throw new Error(`desktop project: plugin ${mutation.name} is not installed`) + writeProfilePlugins(projectDir, plugins.map(plugin => ( + plugin.name === mutation.name ? { ...plugin, enabled: mutation.enabled } : plugin + ))) + return + } default: mutation satisfies never } } - private mergeSeedPnpmState(seedDir: string): void { - const transactionRoot = join(this.paths.staging, randomUUID()) - const extractedStore = join(transactionRoot, 'store') - try { - extractPnpmStoreArchives(seedDir, extractedStore) - mergePnpmStore(extractedStore, this.paths.pnpm.store) - } finally { - removeOwnedDirectory(transactionRoot) - } - } - private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise { const pending: DesktopPendingTransaction = { schemaVersion: 1, id: basename(dirname(stagingProfile)), stagingProfile, + fromRuntimeId: readDesktopProfileState(this.paths.profile)?.runtimeId ?? null, + toRuntimeId: desktopRuntimeId(this.currentRuntime()), step: 'prepared', } writeJson(this.paths.pending, pending) - await hooks.beforeActivate() let activeMoved = false + let stagingActivated = false try { + await hooks.beforeActivate() removeOwnedDirectory(this.paths.rollback) mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 }) writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction) @@ -537,10 +496,11 @@ export class DesktopProjectManager { mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 }) writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction) renameSync(stagingProfile, this.paths.profile) + stagingActivated = true await hooks.afterActivate() unlinkSync(this.paths.pending) } catch (error) { - if (existsSync(this.paths.profile)) removeOwnedDirectory(this.paths.profile) + if (stagingActivated) removeOwnedDirectory(this.paths.profile) if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile) if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending) await hooks.afterActivate().catch(() => undefined) @@ -558,7 +518,7 @@ export class DesktopProjectManager { const npmrc = join(this.paths.pnpm.config, 'npmrc') if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 }) const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))) await new Promise((settle, reject) => { const child = spawn(this.runtime.node, [ @@ -585,19 +545,7 @@ export class DesktopProjectManager { }, stdio: ['ignore', 'pipe', 'pipe'], }) - const childPid = child.pid - if (childPid === undefined) { - child.kill('SIGKILL') - reject(new Error('desktop project: pnpm did not report a process id')) - return - } - try { - this.writeLockOwner(childPid) - } catch (error) { - child.kill('SIGKILL') - reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')) - return - } + let failure: Error | undefined let diagnostics = '' let completed = false const appendDiagnostics = (chunk: string): void => { @@ -618,9 +566,10 @@ export class DesktopProjectManager { } settleChild() } - child.once('error', (error) => { complete(() => { reject(error) }) }) + child.once('error', (error) => { failure = error }) child.once('close', (code, signal) => { complete(() => { + if (failure !== undefined) { reject(failure); return } if (code === 0) { settle() return @@ -630,6 +579,13 @@ export class DesktopProjectManager { )) }) }) + try { + if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id') + this.writeLockOwner(child.pid) + } catch (error) { + failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm') + child.kill('SIGKILL') + } }) } @@ -682,10 +638,10 @@ export class DesktopProjectManager { } } -/** Create seed metadata for one exact Electron and dsh release. */ -export function createSeedMetadata(seedDir: string, release: DesktopRelease): void { - mkdirSync(seedDir, { recursive: true, mode: 0o700 }) - const packageSet = verifyDesktopCorePackageSet(seedDir, release.version) +/** Create build-only project metadata for materializing the signed runtime. */ +export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + const packageSet = verifyDesktopCorePackageSet(projectDir, release.version) const manifest: DesktopProjectManifest = { name: PROJECT_NAME, private: true, @@ -693,13 +649,13 @@ export function createSeedMetadata(seedDir: string, release: DesktopRelease): vo dependencies: desktopCorePackageOverrides(packageSet), dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, } - writeJson(join(seedDir, 'package.json'), manifest) + writeJson(join(projectDir, 'package.json'), manifest) writeFileSync( - join(seedDir, 'pnpm-workspace.yaml'), + join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(desktopCorePackageOverrides(packageSet)), { mode: 0o600 }, ) - writeJson(join(seedDir, 'desktop-release.json'), release) + writeJson(join(projectDir, 'desktop-release.json'), release) } /** @@ -723,3 +679,13 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) writeJson(join(projectDir, 'desktop-release.json'), release) } + +/** Create the first external plugin profile without running a package manager. */ +export function createPluginProfile(projectDir: string): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + writeJson(join(projectDir, 'package.json'), { + name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {}, + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } satisfies DesktopProjectManifest) + writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) +} diff --git a/apps/desktop/src/release.ts b/apps/desktop/src/release.ts index f78d922e8c..88050120e5 100644 --- a/apps/desktop/src/release.ts +++ b/apps/desktop/src/release.ts @@ -1,9 +1,9 @@ -/** Immutable version identity shared by one Electron shell and its dsh seed. */ +/** Immutable version identity shared by one Electron shell and its bundled dsh runtime. */ import { valid } from 'semver' import { DESKTOP_HOST_PROTOCOL_VERSION } from './host-protocol.ts' -/** Release facts embedded in the seed and copied into the active desktop project. */ +/** Release facts embedded in the bundled runtime descriptor. */ export interface DesktopRelease { readonly schemaVersion: 1 /** Exact version used by both Electron and `@deepseek-ai/dsh`. */ diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts new file mode 100644 index 0000000000..471d603964 --- /dev/null +++ b/apps/desktop/src/runtime-tree.ts @@ -0,0 +1,180 @@ +/** Relocatable, integrity-recorded production packages carried by one Desktop release. */ + +import { createHash } from 'node:crypto' +import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { isAbsolute, join, relative, sep } from 'node:path' +import { valid } from 'semver' +import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts' +import { parseDesktopRelease, type DesktopRelease } from './release.ts' + +/** Descriptor at the root of the immutable Desktop resource tree. */ +export const DESKTOP_RUNTIME_FILE = 'desktop-runtime.json' + +/** Host-owned package available to external plugins through a directory link. */ +export interface DesktopSharedPackage { + readonly name: string + readonly version: string + readonly path: string +} + +/** Final bytes and executable permissions of a runtime file. */ +export interface DesktopRuntimeFile { + readonly path: string + readonly bytes: number + readonly sha256: string + readonly executable: boolean +} + +/** One signed application's production dependency tree. */ +export interface DesktopRuntimeDescriptor { + readonly schemaVersion: 1 + readonly release: DesktopRelease + readonly platform: NodeJS.Platform + readonly arch: string + readonly sharedPackages: readonly DesktopSharedPackage[] + readonly files: readonly DesktopRuntimeFile[] +} + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Resolve one portable resource path without permitting traversal or absolute paths. + * @param root - Runtime root. + * @param path - Slash-separated relative path from durable metadata. + * @returns Absolute resource path. + */ +export function runtimePath(root: string, path: string): string { + if (path === '' || isAbsolute(path) || path.includes('\\') || path.includes(':') + || path.split('/').some(part => part === '' || part === '.' || part === '..')) { + throw new Error(`desktop runtime: invalid relative path ${JSON.stringify(path)}`) + } + return join(root, ...path.split('/')) +} + +/** + * Inventory a materialized runtime without following links or including its descriptor. + * @param root - Self-contained runtime directory. + * @returns Sorted final-file inventory. + */ +export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { + const files: DesktopRuntimeFile[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const name = relative(root, path).split(sep).join('/') + if (name === DESKTOP_RUNTIME_FILE) continue + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) { + const body = readFileSync(path) + files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), + executable: (lstatSync(path).mode & 0o111) !== 0 }) + } else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) + } + } + visit(root) + return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +/** + * Seal the final runtime tree after materialization and native signing. + * @param root - Runtime output directory. + * @param release - Matching shell, dsh, Host, and executable versions. + * @param sharedNames - Release-owned packages supplied to plugins. + * @param target - Platform and architecture selected by runtime preparation. + * @returns Descriptor written beside the production packages. + */ +export function writeDesktopRuntime( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string } = process, +): DesktopRuntimeDescriptor { + const sharedPackages = [...new Set(sharedNames)].sort().map((name) => { + if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`) + const path = `node_modules/${name}` + const manifest = JSON.parse(readFileSync(join(runtimePath(root, path), 'package.json'), 'utf8')) as unknown + if (!record(manifest) || manifest.name !== name || typeof manifest.version !== 'string' || valid(manifest.version) === null) { + throw new Error(`desktop runtime: invalid shared package manifest ${name}`) + } + return { name, version: manifest.version, path } + }) + const descriptor: DesktopRuntimeDescriptor = { + schemaVersion: 1, release, platform: target.platform, arch: target.arch, + sharedPackages, files: inventoryDesktopRuntime(root), + } + writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`) + return descriptor +} + +/** + * Verify the runtime before linking any of its packages into writable state. + * @param root - Current application's runtime resources. + * @param electronVersion - Expected shell version. + * @param target - Required execution target; defaults to the current process. + * @returns Validated runtime descriptor. + */ +export function verifyDesktopRuntime( + root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, +): DesktopRuntimeDescriptor { + const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) + if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch + || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) { + throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + } + const release = parseDesktopRelease(value.release) + if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => { + if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name) + || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) { + throw new Error('desktop runtime: invalid shared package record') + } + return { name: entry.name, version: entry.version, path: entry.path } + }) + if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) { + throw new Error('desktop runtime: duplicate shared package') + } + const files = value.files.map((entry: unknown): DesktopRuntimeFile => { + if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number' + || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean' + || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) { + throw new Error('desktop runtime: invalid file inventory') + } + runtimePath(root, entry.path) + return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable } + }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + const actual = inventoryDesktopRuntime(root) + // Windows has no portable Unix executable permission bits. + const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' + ? items.map(({ executable: _executable, ...item }) => item) : items + if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) { + throw new Error('desktop runtime: integrity verification failed') + } + for (const entry of sharedPackages) { + const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) + if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { + throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) + } + } + for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) { + if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) { + throw new Error(`desktop runtime: missing or mismatched ${name}`) + } + } + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) { + throw new Error(`desktop runtime: missing Host file ${file}`) + } + } + return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files } +} + +/** + * Identify exact runtime content independently of its installation path. + * @param descriptor - Validated runtime metadata. + * @returns SHA-256 runtime identity. + */ +export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string { + return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex') +} diff --git a/apps/desktop/src/seed-store.ts b/apps/desktop/src/seed-store.ts deleted file mode 100644 index e4b4759fd1..0000000000 --- a/apps/desktop/src/seed-store.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** Deterministic archive transport for the desktop seed's pnpm store. */ - -import { createHash } from 'node:crypto' -import { - chmodSync, - copyFileSync, - cpSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { join, relative, sep } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { create, extract, list } from 'tar' - -/** Directory containing the seed's uncompressed pnpm store archives. */ -export const SEED_STORE_ARCHIVE_DIR = 'store-archives' - -/** Manifest describing the deterministic pnpm store archive set. */ -export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json' - -const DEFAULT_SHARD_COUNT = 16 -const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u -const STORE_VERSION_PATTERN = /^v\d+$/u - -interface SeedStoreArchiveRecord { - readonly file: string - readonly entries: number -} - -interface SeedStoreArchiveManifest { - readonly schemaVersion: 1 - readonly shardCount: number - readonly archives: readonly SeedStoreArchiveRecord[] -} - -function storeFiles(storeRoot: string): readonly string[] { - const files: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error(`desktop seed: pnpm store contains a symbolic link: ${relative(storeRoot, path)}`) - } - if (entry.isDirectory()) { - visit(path) - continue - } - if (!entry.isFile()) { - throw new Error(`desktop seed: pnpm store contains an unsupported file: ${relative(storeRoot, path)}`) - } - files.push(relative(storeRoot, path).split(sep).join('/')) - } - } - visit(storeRoot) - return files.sort((left, right) => left.localeCompare(right)) -} - -function shardFor(path: string, shardCount: number): number { - return createHash('sha256').update(path).digest().readUInt32BE(0) % shardCount -} - -function readArchiveManifest(seedRoot: string): SeedStoreArchiveManifest { - const path = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) - const value = JSON.parse(readFileSync(path, 'utf8')) as unknown - if (typeof value !== 'object' || value === null) { - throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) - } - const candidate = value as Record - if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.shardCount) - || (candidate.shardCount as number) < 1 || (candidate.shardCount as number) > 256 - || !Array.isArray(candidate.archives) || candidate.archives.length === 0) { - throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) - } - const names = new Set() - const archives = candidate.archives.map((entry): SeedStoreArchiveRecord => { - if (typeof entry !== 'object' || entry === null) { - throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) - } - const record = entry as Record - if (typeof record.file !== 'string' || !ARCHIVE_NAME_PATTERN.test(record.file) - || names.has(record.file) || !Number.isSafeInteger(record.entries) || (record.entries as number) < 1) { - throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) - } - const shard = Number.parseInt(record.file.slice('store-'.length, -'.tar'.length), 16) - if (shard >= (candidate.shardCount as number)) { - throw new Error(`desktop seed: pnpm store archive shard is outside the manifest range in ${path}`) - } - names.add(record.file) - return { file: record.file, entries: record.entries as number } - }) - return { - schemaVersion: 1, - shardCount: candidate.shardCount as number, - archives, - } -} - -function assertArchivePath(path: string): void { - if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0') - || path.split('/').some(part => part === '' || part === '.' || part === '..')) { - throw new Error(`desktop seed: unsafe pnpm store archive path ${JSON.stringify(path)}`) - } -} - -/** - * Remove pnpm's registrations for projects that populated the seed store. - * @param storeRoot - pnpm store directory included in the desktop seed. - */ -export function removePnpmProjectRegistrations(storeRoot: string): void { - for (const entry of readdirSync(storeRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !/^v\d+$/u.test(entry.name)) continue - rmSync(join(storeRoot, entry.name, 'projects'), { recursive: true, force: true }) - } -} - -function mergeStoreIndex(source: string, destination: string): void { - if (!existsSync(destination)) { - copyFileSync(source, destination) - return - } - const database = new DatabaseSync(destination) - let attached = false - try { - database.exec('PRAGMA busy_timeout=5000') - database.prepare('ATTACH DATABASE ? AS seed').run(source) - attached = true - database.exec('BEGIN IMMEDIATE') - let committed = false - try { - database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index') - database.exec('COMMIT') - committed = true - } finally { - if (!committed) database.exec('ROLLBACK') - } - } finally { - if (attached) database.exec('DETACH DATABASE seed') - database.close() - } -} - -/** - * Merge a completely extracted seed store into Desktop's persistent pnpm store. - * @param source - Verified temporary store extraction. - * @param destination - Desktop-owned persistent pnpm store. - */ -export function mergePnpmStore(source: string, destination: string): void { - mkdirSync(destination, { recursive: true, mode: 0o700 }) - const indexPaths = readdirSync(source, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name) - && existsSync(join(source, entry.name, 'index.db'))) - .map(entry => `${entry.name}/index.db`) - const indexes = new Set(indexPaths) - cpSync(source, destination, { - recursive: true, - force: true, - filter: path => !indexes.has(relative(source, path).split(sep).join('/')), - }) - for (const path of indexPaths) { - mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/'))) - } -} - -/** - * Replace a prepared loose pnpm store with deterministic uncompressed archive shards. - * @param seedRoot - seed directory that owns the archive output. - * @param storeRoot - populated pnpm store to archive and remove after success. - * @param shardCount - stable shard count used to limit update churn. - */ -export function archivePnpmStore( - seedRoot: string, - storeRoot: string, - shardCount = DEFAULT_SHARD_COUNT, -): void { - if (!Number.isSafeInteger(shardCount) || shardCount < 1 || shardCount > 256) { - throw new Error(`desktop seed: invalid pnpm store shard count ${shardCount}`) - } - const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) - const manifestPath = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) - rmSync(archiveRoot, { recursive: true, force: true }) - rmSync(manifestPath, { force: true }) - mkdirSync(archiveRoot, { recursive: true }) - const shards = Array.from({ length: shardCount }, (): string[] => []) - for (const path of storeFiles(storeRoot)) (shards[shardFor(path, shardCount)] as string[]).push(path) - const archives: SeedStoreArchiveRecord[] = [] - for (const [index, paths] of shards.entries()) { - if (paths.length === 0) continue - const file = `store-${index.toString(16).padStart(2, '0')}.tar` - create({ - cwd: storeRoot, - file: join(archiveRoot, file), - noDirRecurse: true, - noMtime: true, - portable: true, - sync: true, - }, paths) - chmodSync(join(archiveRoot, file), 0o644) - archives.push({ file, entries: paths.length }) - } - if (archives.length === 0) throw new Error('desktop seed: pnpm store is empty') - writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, shardCount, archives }, undefined, 2)}\n`) - rmSync(storeRoot, { recursive: true }) -} - -/** - * Validate and extract a packaged pnpm store archive set into an empty directory. - * @param seedRoot - verified packaged seed directory. - * @param destination - empty Desktop-owned temporary extraction directory. - */ -export function extractPnpmStoreArchives(seedRoot: string, destination: string): void { - const manifest = readArchiveManifest(seedRoot) - const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) - const actualFiles = readdirSync(archiveRoot, { withFileTypes: true }).map((entry) => { - if (!entry.isFile() || entry.isSymbolicLink()) { - throw new Error(`desktop seed: invalid pnpm store archive entry ${entry.name}`) - } - return entry.name - }).sort() - const expectedFiles = manifest.archives.map(archive => archive.file).sort() - if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { - throw new Error('desktop seed: pnpm store archive set does not match its manifest') - } - if (existsSync(destination) && readdirSync(destination).length !== 0) { - throw new Error(`desktop seed: pnpm store extraction directory is not empty: ${destination}`) - } - mkdirSync(destination, { recursive: true, mode: 0o700 }) - const paths = new Set() - for (const archive of manifest.archives) { - const archivePath = join(archiveRoot, archive.file) - const archiveShard = Number.parseInt(archive.file.slice('store-'.length, -'.tar'.length), 16) - let entries = 0 - list({ - file: archivePath, - onReadEntry: (entry) => { - if (entry.type !== 'File' && entry.type !== 'OldFile') { - throw new Error(`desktop seed: unsupported pnpm store archive entry type ${entry.type}`) - } - assertArchivePath(entry.path) - if (shardFor(entry.path, manifest.shardCount) !== archiveShard) { - throw new Error(`desktop seed: pnpm store path is assigned to the wrong archive shard: ${entry.path}`) - } - if (paths.has(entry.path)) { - throw new Error(`desktop seed: duplicate pnpm store archive path ${entry.path}`) - } - paths.add(entry.path) - entries += 1 - }, - strict: true, - sync: true, - }) - if (entries !== archive.entries) { - throw new Error(`desktop seed: pnpm store archive ${archive.file} has an unexpected entry count`) - } - } - for (const archive of manifest.archives) { - extract({ - chmod: true, - cwd: destination, - file: join(archiveRoot, archive.file), - noMtime: true, - preservePaths: false, - processUmask: 0, - strict: true, - sync: true, - }) - } -} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index c31dcc3093..be629228d8 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -1,4 +1,4 @@ -/** One Electron release stream for the version-bound shell and dsh seed. */ +/** One Electron release stream for the version-bound shell and bundled dsh runtime. */ import { existsSync } from 'node:fs' import { join } from 'node:path' diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index ac1618baef..1a309a2851 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -15,8 +15,8 @@ describe('desktop build paths', () => { 'artifacts', 'runtime', 'packageSet', - 'seed', - 'seedPnpm', + 'dsh', + 'dshPnpm', 'nodeExtract', 'packedDsh', 'packedVendor', @@ -27,7 +27,7 @@ describe('desktop build paths', () => { expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3) } expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts')) - expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed')) + expect(x64.dsh).toContain(join('targets', 'mac-x64', 'dsh')) expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index d3a08d78e0..9a9a2d4730 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -75,6 +75,27 @@ afterEach(() => { }) describe('desktop host process', () => { + it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => { + const runtime = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' }) +function onRequestFrame(frame) { + if (frame.type !== 1) return + responseStart(frame.streamId) + responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH})) + responseEnd(frame.streamId) +} +`) + const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-')) + roots.push(profile) + const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, { + ...process.env, NODE_OPTIONS: '--invalid-desktop-test-option', NODE_PATH: '/unowned', + }) + try { + const response = await host.fetch(new Request('dsh-app://app/environment')) + expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile) }) + } finally { await host.stop() } + }) + it('carries raw request and response bytes and shuts the child down cleanly', async () => { const project = projectWithHost(` const bodies = new Map() @@ -98,7 +119,7 @@ function answer(streamId) { `) const previous = process.env.NODE_OPTIONS process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child' - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' }) const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' })) @@ -124,7 +145,7 @@ function onRequestFrame(frame) { responseEnd(frame.streamId) } `) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const response = await host.fetch(new Request('dsh-app://app/large')) const body = new Uint8Array(await response.arrayBuffer()) @@ -151,7 +172,7 @@ function onRequestFrame(frame) { start(controller) { controller.enqueue(Buffer.from('first')) }, cancel() { canceled = true }, }) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const request = new Request('dsh-app://app/early', { method: 'POST', @@ -184,7 +205,7 @@ function onRequestFrame(frame) { } } `) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const canceled = await host.fetch(new Request('dsh-app://app/cancel')) await canceled.body?.cancel() @@ -202,7 +223,7 @@ process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' }) function onRequestFrame(frame) { if (frame.type === 1) responsePipe.write(Buffer.alloc(13)) } -`)) +`), projectWithHost('')) await invalid.start() await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u) await invalid.stop().catch(() => undefined) @@ -210,7 +231,7 @@ function onRequestFrame(frame) { const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(` function onRequestFrame() {} process.exit(0) -`)) +`), projectWithHost('')) await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u) }) }) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts new file mode 100644 index 0000000000..c33b8d8cfb --- /dev/null +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { signMacOSRuntime } from '../scripts/macos-runtime.ts' +import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' + +vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() })) +const roots: string[] = [] +function root(): string { + const path = mkdtempSync(join(tmpdir(), 'desktop-signing-')) + roots.push(path) + return path +} +const identity = { signingIdentity: 'Example (TEAMID1234)', teamId: 'TEAMID1234' } +afterEach(() => { + vi.resetAllMocks() + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }) +}) +it('signs Mach-O files in their final locations and verifies each signature', async () => { + const path = root() + writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex')) + writeFileSync(join(path, 'source.js'), 'export {}') + await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1) + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity) + expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) +}) +it('awaits other signers before rejecting and permitting output cleanup', async () => { + const path = root() + for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex')) + let release!: () => void + const barrier = new Promise((resolve) => { release = resolve }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + vi.mocked(signMacOSRuntimeCode).mockImplementation(async (file) => { + if (file.endsWith('a.node')) throw new Error('sign failure') + started() + await barrier + }) + let completed = false + const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error }) + try { + await ready + expect(completed).toBe(false) + } finally { release() } + expect(await result).toBeInstanceOf(AggregateError) + expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) +}) diff --git a/apps/desktop/tests/macos-seed-store.spec.ts b/apps/desktop/tests/macos-seed-store.spec.ts deleted file mode 100644 index 2155389262..0000000000 --- a/apps/desktop/tests/macos-seed-store.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createHash } from 'node:crypto' -import { - appendFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { Packr } from 'msgpackr' -import { afterEach, describe, expect, it } from 'vitest' -import { - signMacOSSeedStore, - verifyMacOSSeedStore, -} from '../scripts/macos-seed-store.ts' - -const temporaryRoots: string[] = [] -const packr = new Packr({ moreTypes: true, useRecords: true }) -const SIGNING_ENVIRONMENT = { - signingIdentity: 'Example Company (TEAMID1234)', - teamId: 'TEAMID1234', -} - -function temporaryRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-test-')) - temporaryRoots.push(root) - return root -} - -function casPath(store: string, body: Buffer, executable = false): { digest: string; path: string } { - const digest = createHash('sha512').update(body).digest('hex') - return { - digest, - path: join(store, 'v11', 'files', digest.slice(0, 2), `${digest.slice(2)}${executable ? '-exec' : ''}`), - } -} - -function createStoreFile(path: string, body: Buffer): void { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, body) -} - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop macOS seed store signing', () => { - it('rehashes signed Mach-O content, rewrites every package reference, and prunes native orphans', async () => { - const store = temporaryRoot() - const native = Buffer.concat([Buffer.from('cffaedfe', 'hex'), Buffer.from('native-code')]) - const nativeCas = casPath(store, native) - createStoreFile(nativeCas.path, native) - const orphan = Buffer.concat([Buffer.from('cafebabe', 'hex'), Buffer.from('orphan')]) - const orphanCas = casPath(store, orphan) - createStoreFile(orphanCas.path, orphan) - const plain = Buffer.from('plain package content') - const plainCas = casPath(store, plain) - createStoreFile(plainCas.path, plain) - - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - for (const key of ['package-a', 'package-b']) { - insert.run(key, packr.pack({ - algo: 'sha512', - files: new Map([ - ['native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], - ['index.js', { checkedAt: 1, digest: plainCas.digest, mode: 0o644, size: plain.length }], - ]), - sideEffects: key === 'package-b' - ? new Map([['build', { added: new Map([ - ['built/native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], - ]) }]]) - : undefined, - })) - } - database.close() - - const result = await signMacOSSeedStore( - store, - 'com.example.desktop', - SIGNING_ENVIRONMENT, - { - signer: async (path, identifier) => { - expect(identifier).toBe(`com.example.desktop.seed.${nativeCas.digest.slice(0, 32)}`) - appendFileSync(path, 'signed') - }, - }, - ) - - expect(result).toEqual({ signedFiles: 1, prunedOrphans: 1, updatedIndexRows: 2 }) - expect(existsSync(nativeCas.path)).toBe(false) - expect(existsSync(orphanCas.path)).toBe(false) - expect(readFileSync(plainCas.path)).toEqual(plain) - - const updated = new DatabaseSync(join(store, 'v11', 'index.db'), { readOnly: true }) - const digests = [...updated.prepare('SELECT data FROM package_index').iterate() as Iterable<{ data: Uint8Array }>] - .flatMap((row) => { - const record = packr.unpack(row.data) as { - files: Map - sideEffects?: Map }> - } - return [ - record.files.get('native.node')?.digest, - ...[...(record.sideEffects?.values() ?? [])].map(effect => effect.added.get('built/native.node')?.digest), - ].filter((digest): digest is string => digest !== undefined) - }) - updated.close() - expect(new Set(digests).size).toBe(1) - expect(digests).toHaveLength(3) - expect(digests[0]).not.toBe(nativeCas.digest) - - const verified: string[] = [] - expect(verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, (path) => { verified.push(path) })).toBe(1) - expect(verified).toHaveLength(1) - }) - - it('bounds concurrent signing while allowing independent Mach-O files to overlap', async () => { - const store = temporaryRoot() - const files = new Map() - for (let index = 0; index < 6; index += 1) { - const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) - const nativeCas = casPath(store, body) - createStoreFile(nativeCas.path, body) - files.set(`native-${index}.node`, { - checkedAt: 1, - digest: nativeCas.digest, - mode: 0o644, - size: body.length, - }) - } - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - .run('package', packr.pack({ algo: 'sha512', files })) - database.close() - - let started = 0 - let active = 0 - let maximumActive = 0 - let releaseSigning = (): void => {} - const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) - let markFirstWaveReady = (): void => {} - const firstWaveReady = new Promise((resolve) => { markFirstWaveReady = resolve }) - const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 4, - signer: async () => { - started += 1 - active += 1 - maximumActive = Math.max(maximumActive, active) - if (started === 4) markFirstWaveReady() - try { - await signingReleased - } finally { - active -= 1 - } - }, - }) - - await firstWaveReady - expect({ started, active, maximumActive }).toEqual({ started: 4, active: 4, maximumActive: 4 }) - releaseSigning() - await expect(signing).resolves.toMatchObject({ signedFiles: 6, updatedIndexRows: 1 }) - expect({ started, active, maximumActive }).toEqual({ started: 6, active: 0, maximumActive: 4 }) - }) - - it('awaits active signers and preserves the store when one signer fails', async () => { - const store = temporaryRoot() - const originals: { digest: string; path: string }[] = [] - const files = new Map() - for (let index = 0; index < 2; index += 1) { - const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) - const nativeCas = casPath(store, body) - originals.push(nativeCas) - createStoreFile(nativeCas.path, body) - files.set(`native-${index}.node`, { - checkedAt: 1, - digest: nativeCas.digest, - mode: 0o644, - size: body.length, - }) - } - const databasePath = join(store, 'v11', 'index.db') - const database = new DatabaseSync(databasePath) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - .run('package', packr.pack({ algo: 'sha512', files })) - database.close() - const originalIndex = readFileSync(databasePath) - - let started = 0 - let settled = 0 - let releaseSigning = (): void => {} - const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) - let markBothReady = (): void => {} - const bothReady = new Promise((resolve) => { markBothReady = resolve }) - const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 2, - signer: async () => { - started += 1 - const call = started - if (started === 2) markBothReady() - try { - await signingReleased - if (call === 1) throw new Error('signing failed') - } finally { - settled += 1 - } - }, - }) - - await bothReady - releaseSigning() - await expect(signing).rejects.toThrow(/signing failed/u) - expect({ started, settled }).toEqual({ started: 2, settled: 2 }) - expect(readFileSync(databasePath)).toEqual(originalIndex) - for (const original of originals) expect(existsSync(original.path)).toBe(true) - }) - - it('rejects an invalid signing worker bound before starting a signer', async () => { - const store = temporaryRoot() - mkdirSync(join(store, 'v11', 'files'), { recursive: true }) - await expect(signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 0, - signer: async () => {}, - })).rejects.toThrow(/positive integer/u) - }) - - it('propagates a signature-verification failure', () => { - const store = temporaryRoot() - const native = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from('native-code')]) - const nativeCas = casPath(store, native) - createStoreFile(nativeCas.path, native) - - expect(() => { - verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, () => { - throw new Error('invalid signature') - }) - }).toThrow(/invalid signature/u) - }) -}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 827de532e9..09adb91bf1 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -1,3 +1,10 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { createRequire } from 'node:module' +import { FileMatcher } from 'app-builder-lib/out/fileMatcher.js' +import { runtimeFixture } from './runtime-fixture.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import type { NotarizeOptions } from '@electron/notarize' import { @@ -7,10 +14,15 @@ import { } from '../scripts/desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs' import { - assertMacOSSeedSignatureDetails, + assertMacOSRuntimeSignatureDetails, assertMacOSSignatureDetails, } from '../scripts/verify-macos-signature.mjs' +// app-builder-lib omits this internal copier from its declarations; the regression exercises its actual file filter. +const { copyFiles } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as { + copyFiles: (matchers: FileMatcher[]) => Promise +} + const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_TARGET_PLATFORM: 'darwin', @@ -40,17 +52,18 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') - expect(config.extraResources).toHaveLength(2) + expect(config.extraResources).toHaveLength(3) expect(config.extraResources[0]?.to).toBe('runtime') - expect(config.extraResources[1]?.to).toBe('seed') + expect(config.extraResources[1]?.to).toBe('dsh') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') - expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/seed') + expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/dsh') expect(config).toMatchObject({ appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, mac: { identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, forceCodeSigning: true, notarize: true, + signIgnore: ['/Contents/Resources/dsh(?:/|$)'], }, dmg: { sign: true, @@ -64,6 +77,26 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('copies the complete runtime despite electron-builder excluding root node_modules', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + const root = mkdtempSync(join(tmpdir(), 'desktop-resource-copy-')) + try { + const source = join(root, 'source') + const destination = join(root, 'resources') + runtimeFixture(source) + const sourceRoot = config.extraResources[1].from + const matchers = config.extraResources.slice(1).map(entry => new FileMatcher( + join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value, + )) + await copyFiles(matchers.slice(0, 1)) + expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).toThrow(/integrity/u) + rmSync(destination, { recursive: true }) + await copyFiles(matchers) + expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).not.toThrow() + } finally { rmSync(root, { recursive: true, force: true }) } + }) + it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') expect(() => createElectronBuilderConfig({ @@ -82,7 +115,7 @@ describe('desktop macOS release signature', () => { }).not.toThrow() }) - it('requires a secure timestamp and hardened runtime for seed code', () => { + it('requires a secure timestamp and hardened runtime for runtime code', () => { const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) const details = [ `Authority=Developer ID Application: ${expected.signingIdentity}`, @@ -90,12 +123,12 @@ describe('desktop macOS release signature', () => { 'Timestamp=31 Aug 2026 at 20:00:00', 'CodeDirectory v=20500 size=773 flags=0x10000(runtime) hashes=13+7 location=embedded', ].join('\n') - expect(() => { assertMacOSSeedSignatureDetails(details, expected) }).not.toThrow() + expect(() => { assertMacOSRuntimeSignatureDetails(details, expected) }).not.toThrow() expect(() => { - assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) + assertMacOSRuntimeSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) }).toThrow(/secure timestamp/u) expect(() => { - assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) + assertMacOSRuntimeSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) }).toThrow(/hardened runtime/u) }) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index e822f212f5..ae5e0ca800 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -56,7 +56,7 @@ describe('desktop package target', () => { expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') }) - it('keeps Windows signing fields out of build and seed preparation subprocesses', () => { + it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => { expect(withoutWindowsSigningEnvironment({ DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts new file mode 100644 index 0000000000..376b523f58 --- /dev/null +++ b/apps/desktop/tests/plugin-manager.spec.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' + +it('keeps disabled packages visible and offers recovery without a running backend', async () => { + const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + let enabled = true + let ready = false + const disableAll = vi.fn(async () => { enabled = false; ready = true }) + const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) + const api = { + locale: async () => resolveDesktopLocale('en'), + backend: { status: async () => ({ ready, error: ready ? undefined : 'plugin requires Cordis ^2.0.0' }), retry: vi.fn() }, + plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle }, + } + Object.defineProperty(dom.window, 'dshDesktop', { value: api }) + try { + runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + const document = dom.window.document + await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') + expect(document.querySelector('#recovery')?.hidden).toBe(false) + expect(document.querySelector('#startup-error')?.textContent).toBe('plugin requires Cordis ^2.0.0') + document.querySelector('#disable-all')?.click() + await expect.poll(() => document.querySelector('#recovery')?.hidden).toBe(true) + expect(disableAll).toHaveBeenCalledOnce() + expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"') + document.querySelector('#plugins li button')?.click() + await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]]) + await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove') + } finally { dom.window.close() } +}) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts new file mode 100644 index 0000000000..1a455e3453 --- /dev/null +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { c } from 'tar' +import { expect, it } from 'vitest' +import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' +import { resolveDesktopPaths } from '../src/paths.ts' +import { runtimeFixture, writePackage } from './runtime-fixture.ts' + +it('installs a real pnpm graph, then executes approved scripts with the shared host instance', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-'))) + const server = createServer() + const archives = new Map() + try { + for (const name of ['fixture-plugin', 'node-pty']) { + const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin' + ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } } + : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"') + writeFileSync(join(path, 'bundle.yml'), '[]\n') + writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath, host:require.resolve("@deepseek-ai/cordis")}))') + const tarball = join(root, `${name}.tgz`) + await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name]) + archives.set(name, readFileSync(tarball)) + } + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address') + const origin = `http://127.0.0.1:${address.port}` + server.on('request', (request, response) => { + const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? '' + const archive = archives.get(name) + if (archive === undefined) { response.writeHead(404); response.end(); return } + if (request.url?.endsWith('.tgz')) { response.end(archive); return } + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': { + name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` }, + ...(name === 'fixture-plugin' ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : {}), + } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } })) + }) + const dsh = join(root, 'dsh') + runtimeFixture(dsh) + const pnpm = join(root, 'pnpm.mjs') + const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') + writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) + const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) + const hooks: DesktopProjectHooks = { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {} } + await manager.applyRelease('1.0.0', hooks) + await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) + expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) + const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string } + expect(realpathSync(built.node)).toBe(realpathSync(process.execPath)) + expect(built.host).toBe(join(dsh, 'node_modules/@deepseek-ai/cordis/index.js')) + const entry = join(dsh, 'identity.mjs') + writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`) + expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true') + await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks) + expect(manager.listPlugins()).toEqual([]) + } finally { + server.closeAllConnections() + if (server.listening) await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + }) + rmSync(root, { recursive: true, force: true }) + } +}, 30_000) diff --git a/apps/desktop/tests/profile-packages.spec.ts b/apps/desktop/tests/profile-packages.spec.ts new file mode 100644 index 0000000000..4f3fe1308a --- /dev/null +++ b/apps/desktop/tests/profile-packages.spec.ts @@ -0,0 +1,81 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it } from 'vitest' +import { createPluginProfile } from '../src/project-manager.ts' +import { copyDesktopProfile, linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import { runtimeFixture, writePackage } from './runtime-fixture.ts' + +const roots: string[] = [] +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'desktop-profile-')) + roots.push(root) + const dsh = join(root, 'dsh') + const runtime = runtimeFixture(dsh) + const profile = join(root, 'profile') + createPluginProfile(profile) + linkDesktopHostPackages(profile, dsh, runtime) + return { root, dsh, runtime, profile } +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('loads one shared ESM instance from both host and external plugin while keeping ordinary dependencies private', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(dsh, 'node_modules'), 'ordinary', {}, 'export default "host"') + writePackage(join(profile, 'node_modules'), 'ordinary', {}, 'export default "plugin"') + const plugin = writePackage(join(profile, 'node_modules'), 'plugin', { + peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dependencies: { ordinary: '1.0.0' }, + }, 'export { identity } from "@deepseek-ai/cordis"; export { default as ordinary } from "ordinary"') + validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) + const entry = join(dsh, 'check.mjs') + writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import ordinary from 'ordinary'; import * as plugin from ${JSON.stringify(pathToFileURL(join(plugin, 'index.js')).href)}; console.log(JSON.stringify({same:identity===plugin.identity, host:ordinary, plugin:plugin.ordinary}))`) + const output = execFileSync(process.execPath, [entry], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } }) + expect(JSON.parse(output)).toEqual({ same: true, host: 'host', plugin: 'plugin' }) +}) +it.each(['nested', 'alias'])('rejects a %s second copy of a host package', (placement) => { + const { dsh, runtime, profile } = fixture() + const plugin = writePackage(join(profile, 'node_modules'), 'plugin') + if (placement === 'nested') writePackage(join(plugin, 'node_modules'), '@deepseek-ai/cordis') + else writePackage(join(profile, 'node_modules'), 'alias', { name: '@deepseek-ai/cordis' }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/duplicate or aliased/u) +}) +it('rejects a host package declared as an ordinary dependency', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { '@deepseek-ai/cordis': '^1.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/peer dependency/u) +}) +it('rejects incompatible peers only when the plugin is enabled', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin', { peerDependencies: { '@deepseek-ai/cordis': '^2.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/found 1.0.0/u) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, []) }).not.toThrow() +}) +it('refuses to satisfy a plugin dependency from an ancestor CLI project', () => { + const { root, dsh, runtime, profile } = fixture() + writePackage(join(root, 'node_modules'), 'ambient') + writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u) +}) +it('copies writable plugin bytes independently and removes broken owned links without following them', () => { + const { root, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin') + copyDesktopProfile(profile, join(root, 'copy')) + writeFileSync(join(root, 'copy/node_modules/plugin/index.js'), 'changed') + expect(readFileSync(join(profile, 'node_modules/plugin/index.js'), 'utf8')).toContain('identity') + rmSync(join(root, 'dsh'), { recursive: true }) + expect(() =>{ unlinkDesktopHostPackages(profile) }).not.toThrow() +}) +it('refuses to replace an unowned package at a managed name', () => { + const { profile } = fixture() + unlinkSync(join(profile, 'node_modules/@deepseek-ai/cordis')) + writePackage(join(profile, 'node_modules'), '@deepseek-ai/cordis') + expect(() =>{ unlinkDesktopHostPackages(profile) }).toThrow(/unowned package/u) +}) +it('rejects private package links instead of following cycles or old transaction paths', () => { + const { dsh, runtime, profile } = fixture() + const plugin = writePackage(join(profile, 'node_modules'), 'plugin') + symlinkSync(plugin, join(profile, 'node_modules/alias'), process.platform === 'win32' ? 'junction' : 'dir') + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/linked private package/u) +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index f5320b47bd..18e5c26c7a 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,392 +1,217 @@ -import { createHash } from 'node:crypto' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative, sep } from 'node:path' +import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' -import { - createSeedMetadata, - DesktopProjectManager, - packageNameFromSpec, - verifySeedIntegrity, - type DesktopProjectHooks, -} from '../src/project-manager.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { DESKTOP_PACKAGES_DIR, DESKTOP_PACKAGE_SET_FILE } from '../src/core-package-set.ts' -import type { DesktopRelease } from '../src/release.ts' -import { archivePnpmStore } from '../src/seed-store.ts' +import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts' +import { readDesktopProfileState } from '../src/profile-packages.ts' +import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] - function temporaryRoot(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) roots.push(root) return root } - -function writeIntegrity(seed: string): void { - const paths: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) visit(path) - else if (entry.name !== 'integrity.json') paths.push(path) - } - } - visit(seed) - const files = paths.sort().map((path) => { - const body = readFileSync(path) - return { - path: relative(seed, path).split(sep).join('/'), - bytes: statSync(path).size, - sha256: createHash('sha256').update(body).digest('hex'), - } - }) - writeFileSync(join(seed, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files })}\n`) -} - -function archiveStore(seed: string): void { - const store = join(seed, 'store') - mkdirSync(store, { recursive: true }) - if (readdirSync(store).length === 0) writeFileSync(join(store, 'test-entry'), 'content') - archivePnpmStore(seed, store) -} - -function writeCorePackageSet(seed: string, version: string): void { - const packages = [ - { name: '@deepseek-ai/dsh', file: `deepseek-ai-dsh-${version}.tgz`, body: Buffer.from(`dsh-${version}`) }, - { - name: '@deepseek-ai/dsh-desktop-host', - file: `deepseek-ai-dsh-desktop-host-${version}.tgz`, - body: Buffer.from(`desktop-host-${version}`), - }, - ] - mkdirSync(join(seed, DESKTOP_PACKAGES_DIR), { recursive: true }) - for (const entry of packages) writeFileSync(join(seed, DESKTOP_PACKAGES_DIR, entry.file), entry.body) - writeFileSync(join(seed, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({ - schemaVersion: 1, - packages: packages.map(({ name, file, body }) => ({ - name, - version, - file, - bytes: body.byteLength, - integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, - })), - })}\n`) -} - -function createTestSeedMetadata(seed: string, desktopRelease: DesktopRelease): void { - writeCorePackageSet(seed, desktopRelease.version) - createSeedMetadata(seed, desktopRelease) -} - function writeFakePnpm(root: string): string { const path = join(root, 'pnpm.mjs') - writeFileSync(path, String.raw` -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' + writeFileSync(path, ` +import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' const args = process.argv.slice(2) const project = process.cwd() -const command = args.find(value => value === 'install' || value === 'add' || value === 'remove') -const manifestPath = join(project, 'package.json') -const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) -const packageName = spec => spec.startsWith('@') - ? spec.slice(0, spec.indexOf('@', spec.indexOf('/') + 1) === -1 ? undefined : spec.indexOf('@', spec.indexOf('/') + 1)) - : spec.split('@')[0] -const packageVersion = spec => { - const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@') - return index === -1 ? '1.0.0' : spec.slice(index + 1) -} - -if (command === 'add') { - const spec = args[args.indexOf('add') + 1] - manifest.dependencies[packageName(spec)] = packageVersion(spec) -} -if (command === 'remove') delete manifest.dependencies[args[args.indexOf('remove') + 1]] -writeFileSync(manifestPath, JSON.stringify(manifest)) -rmSync(join(project, 'node_modules'), { recursive: true, force: true }) -for (const [name, version] of Object.entries(manifest.dependencies)) { - const packageRoot = join(project, 'node_modules', ...name.split('/')) - mkdirSync(packageRoot, { recursive: true }) - const core = name === '@deepseek-ai/dsh' || name === '@deepseek-ai/dsh-desktop-host' - const plugin = !core - const installedVersion = plugin - ? version - : JSON.parse(readFileSync(join(project, 'desktop-release.json'), 'utf8')).version - writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ - name, version: installedVersion, - ...(plugin ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}), - })) - if (plugin) writeFileSync(join(packageRoot, 'bundle.yml'), '[]\n') - else if (name === '@deepseek-ai/dsh-desktop-host') { - mkdirSync(join(packageRoot, 'lib'), { recursive: true }) - writeFileSync(join(packageRoot, 'lib', 'index.js'), '') +const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value)) +appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY}) + '\\n') +if (command !== 'rebuild') { + const manifestPath = join(project, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (command === 'add') { + const spec = args[args.indexOf(command) + 1] + const index = spec.lastIndexOf('@') + const name = index > 0 ? spec.slice(0, index) : spec + manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0' } + if (command === 'remove') delete manifest.dependencies[args[args.indexOf(command) + 1]] + writeFileSync(manifestPath, JSON.stringify(manifest)) + rmSync(join(project, 'node_modules'), { recursive: true, force: true }) + for (const [name, version] of Object.entries(manifest.dependencies)) { + const packageRoot = join(project, 'node_modules', name) + mkdirSync(packageRoot, { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version, + peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}})) + writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n') + } + writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies)) } -writeFileSync(join(project, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') -if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.stringify({ args, env: process.env })) `) return path } - -function writeBlockingFakePnpm(root: string, ready: string, release: string): string { - const path = join(root, 'blocking-pnpm.mjs') - const delegate = writeFakePnpm(root) - writeFileSync(path, ` -import { existsSync, writeFileSync } from 'node:fs' -import { setTimeout as sleep } from 'node:timers/promises' -writeFileSync(${JSON.stringify(ready)}, String(process.pid)) -while (!existsSync(${JSON.stringify(release)})) await sleep(10) -await import(${JSON.stringify(pathToFileURL(delegate).href)}) -`) - return path -} - function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { - healthCheck: async () => {}, - beforeActivate: async () => {}, - afterActivate: async () => {}, - ...overrides, - } + return { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } } - -function release(version = '1.0.0'): DesktopRelease { - return { - schemaVersion: 1, - version, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - nodeVersion: '24.17.0', - pnpmVersion: '11.7.0', - } +function setup(): { root: string; manager: DesktopProjectManager } { + const root = temporaryRoot() + const dsh = join(root, 'resources', 'dsh') + runtimeFixture(dsh) + return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) } } +function calls(root: string): { args: string[]; registry: string }[] { + const path = join(root, 'pnpm-log.jsonl') + return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : [] +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop package policy', () => { - it('accepts registry package specs but rejects alternate sources and flags', () => { +describe('desktop external plugin profile', () => { + it('accepts registry names and tags but rejects alternate sources and flags', () => { expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin') expect(packageNameFromSpec('plugin@next')).toBe('plugin') - expect(() => packageNameFromSpec('file:../plugin')).toThrow(/unsupported npm package spec/u) - expect(() => packageNameFromSpec('--registry=evil')).toThrow(/unsupported npm package spec/u) - expect(() => packageNameFromSpec('https://example.test/plugin.tgz')).toThrow(/unsupported npm package spec/u) - }) - - it('rejects any seed content changed after release inventory generation', () => { - const seed = join(temporaryRoot(), 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - writeIntegrity(seed) - expect(() => { verifySeedIntegrity(seed) }).not.toThrow() - writeFileSync(join(seed, 'package.json'), '{}\n') - expect(() => { verifySeedIntegrity(seed) }).toThrow(/integrity verification failed/u) - }) -}) - -describe('desktop project transactions', () => { - it('installs the offline seed and reconciles a mismatched private Host', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const log = join(root, 'pnpm-log.json') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(seed, 'store'), { recursive: true }) - writeFileSync(join(seed, 'store', 'seed-entry'), 'content') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - const previousLog = process.env.TEST_PNPM_LOG - const previousRegistry = process.env.npm_config_registry - process.env.TEST_PNPM_LOG = log - process.env.npm_config_registry = 'https://user-registry.invalid' - try { - await expect(manager.applyRelease(seed, '2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) - await manager.applyRelease(seed, '1.0.0', hooks()) - writeFileSync( - join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), - '{"name":"@deepseek-ai/dsh-desktop-host","version":"0.9.0"}\n', - ) - await expect(manager.applyRelease(seed, '1.0.0', hooks())).resolves.toBe(true) - } finally { - if (previousLog === undefined) delete process.env.TEST_PNPM_LOG - else process.env.TEST_PNPM_LOG = previousLog - if (previousRegistry === undefined) delete process.env.npm_config_registry - else process.env.npm_config_registry = previousRegistry + for (const spec of ['file:../plugin', '--registry=evil', 'https://example.test/plugin.tgz']) { + expect(() => packageNameFromSpec(spec)).toThrow(/unsupported npm package spec/u) } - expect(manager.dshVersion()).toBe('1.0.0') - expect(manager.releaseVersion()).toBe('1.0.0') - expect(paths.profile).toBe(join(root, '.dsh', 'profiles', 'desktop')) - expect(existsSync(join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(true) - const installedHost = JSON.parse(readFileSync( - join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), - 'utf8', - )) as { version: string } - expect(installedHost.version).toBe('1.0.0') - expect(existsSync(join(paths.profile, 'desktop-plugins.json'))).toBe(false) - expect(readFileSync(join(paths.pnpm.store, 'seed-entry'), 'utf8')).toBe('content') - const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } - expect(invocation.args).toContain('--offline') - expect(invocation.args).toContain('--trust-lockfile') - expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`) - expect(invocation.args).toContain('--config.enable-global-virtual-store=false') - expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store) - expect(invocation.env.NPM_CONFIG_USERCONFIG).toBe(join(paths.pnpm.config, 'npmrc')) - expect(invocation.env.npm_config_registry).toBeUndefined() }) - it('restores the active project when the replacement backend cannot start', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) - let starts = 0 - await expect(manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks({ - afterActivate: async () => { - starts += 1 - if (starts === 1) throw new Error('backend rejected staged graph') - }, - }))).rejects.toThrow(/backend rejected staged graph/u) + it('initializes and restarts offline without executing pnpm', async () => { + const { root, manager } = setup() + await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) + await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) + await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false) expect(manager.listPlugins()).toEqual([]) - expect(manager.dshVersion()).toBe('1.0.0') - expect(starts).toBe(2) + expect(calls(root)).toEqual([]) + expect(existsSync(manager.paths.pnpm.store)).toBe(false) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis'))) + expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} }) }) - it('restores rollback when the active move completed before its journal update', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) + it('repairs a removed managed link without running pnpm', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')) + await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) + expect(calls(root)).toEqual([]) + }) + + it('installs only plugins and checks the graph before running lifecycle scripts', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - const stagingProfile = join(paths.staging, 'interrupted', 'profile') - mkdirSync(stagingProfile, { recursive: true }) - writeFileSync(join(stagingProfile, 'marker'), 'staging') - rmSync(paths.rollback, { recursive: true, force: true }) - mkdirSync(dirname(paths.rollback), { recursive: true }) - renameSync(paths.profile, paths.rollback) - writeFileSync(paths.pending, `${JSON.stringify({ - schemaVersion: 1, - id: 'interrupted', - stagingProfile, - step: 'prepared', - })}\n`) - - manager.recover() - - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) - expect(existsSync(stagingProfile)).toBe(false) - expect(existsSync(paths.pending)).toBe(false) - }) - - it('records the live pnpm worker as transaction owner until it exits', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const ready = join(root, 'pnpm-ready') - const releaseWorker = join(root, 'pnpm-release') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) } - const manager = new DesktopProjectManager(paths, runtime) - const installing = manager.applyRelease(seed, '1.0.0', hooks()) - await expect.poll(() => existsSync(ready)).toBe(true) - const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10) - expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`) - const competing = new DesktopProjectManager(paths, runtime) - await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u) - writeFileSync(releaseWorker, 'continue') - await expect(installing).resolves.toBe(true) - expect(existsSync(paths.lock)).toBe(false) - }) - - it('keeps core packages local while installing plugins from the desktop registry', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const log = join(root, 'pnpm-log.json') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) - const previousLog = process.env.TEST_PNPM_LOG - process.env.TEST_PNPM_LOG = log - try { - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - } finally { - if (previousLog === undefined) delete process.env.TEST_PNPM_LOG - else process.env.TEST_PNPM_LOG = previousLog - } - - const manifest = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { - dependencies: Record - } - const coreSpec = manifest.dependencies['@deepseek-ai/dsh'] - expect(coreSpec).toMatch(/^file:\.\/desktop-packages\//u) - expect(readFileSync(join(paths.profile, 'pnpm-workspace.yaml'), 'utf8')) - .toContain(`${JSON.stringify('@deepseek-ai/dsh')}: ${JSON.stringify(coreSpec)}`) - expect(manifest.dependencies['@scope/plugin']).toBe('2.0.0') - const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } - expect(invocation.args).toContain('add') - expect(invocation.args).toContain('@scope/plugin@2.0.0') - expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') - }) - - it('reconciles dsh to the packaged release without removing desktop plugins', async () => { - const root = temporaryRoot() - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - const firstSeed = join(root, 'seed-1') - createTestSeedMetadata(firstSeed, release('1.0.0')) - writeFileSync(join(firstSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(firstSeed, 'store'), { recursive: true }) - writeFileSync(join(firstSeed, 'store', 'release-1'), 'one') - archiveStore(firstSeed) - writeIntegrity(firstSeed) - await manager.applyRelease(firstSeed, '1.0.0', hooks()) - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - - const nextSeed = join(root, 'seed-2') - createTestSeedMetadata(nextSeed, release('1.1.0')) - writeFileSync(join(nextSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(nextSeed, 'store'), { recursive: true }) - writeFileSync(join(nextSeed, 'store', 'release-2'), 'two') - archiveStore(nextSeed) - writeIntegrity(nextSeed) - - await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(true) - expect(manager.releaseVersion()).toBe('1.1.0') - expect(manager.dshVersion()).toBe('1.1.0') - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) - const profile = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { - dsh: { profile: { bundles: string[] } } - } - expect(profile.dsh.profile.bundles).toEqual([ - '@deepseek-ai/dsh-base', - '@deepseek-ai/dsh-web-app', - '@scope/plugin', + expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) + expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ + ['add', '@scope/plugin@2.0.0', '--save-exact', '--ignore-scripts'], ['rebuild', '--pending'], ]) - expect(readFileSync(join(paths.pnpm.store, 'release-1'), 'utf8')).toBe('one') - expect(readFileSync(join(paths.pnpm.store, 'release-2'), 'utf8')).toBe('two') - await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(false) + expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true) + expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } }) + await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u) + }) + + it('retains disabled plugin versions through updates and enables them explicitly', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + await manager.mutate({ type: 'plugins-disable-all' }, hooks()) + expect(calls(root)).toHaveLength(2) + expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks()) + expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }]) + await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks()) + expect(manager.listPlugins()[0]?.enabled).toBe(true) + await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) + expect(manager.listPlugins()).toEqual([]) + }) + + it('keeps plugin files and patches through a compatible release and application relocation', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') + const nextRoot = join(root, 'relocated', 'dsh') + runtimeFixture(nextRoot, '1.1.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) + await expect(next.applyRelease('1.1.0', hooks())).resolves.toBe(true) + expect(next.listPlugins()).toEqual(manager.listPlugins()) + expect(next.releaseVersion()).toBe('1.1.0') + expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') + expect(calls(root)).toHaveLength(2) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis'))) + writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only') + expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') + }) + + it('reinstalls the locked plugin graph when bundled Node changes', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await next.applyRelease('1.1.0', hooks()) + expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ + ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'], + ]) + expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + }) + + it('allows incompatible plugins to be disabled in recovery without deleting them', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'next-major') + runtimeFixture(dsh, '2.0.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u) + expect(next.releaseVersion()).toBe('1.0.0') + expect(() =>{ next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u) + await next.mutate({ type: 'plugins-disable-all' }, hooks()) + expect(next.releaseVersion()).toBe('2.0.0') + expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + }) + + it.each(['health', 'before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { + const { manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + let starts = 0 + await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ + healthCheck: async () => { if (phase === 'health') throw new Error('health failed') }, + beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') }, + afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') }, + }))).rejects.toThrow(`${phase} failed`) + expect(manager.listPlugins()).toEqual([]) + expect(existsSync(manager.paths.pending)).toBe(false) + }) + + it('recovers a directory move using recorded runtime identities', async () => { + const { manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + const id = readDesktopProfileState(manager.paths.profile)?.runtimeId + const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile') + mkdirSync(stagingProfile, { recursive: true }) + mkdirSync(dirname(manager.paths.rollback), { recursive: true }) + renameSync(manager.paths.profile, manager.paths.rollback) + writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' })) + manager.recover() + expect(manager.releaseVersion()).toBe('1.0.0') + expect(existsSync(stagingProfile)).toBe(false) + expect(existsSync(manager.paths.pending)).toBe(false) + }) + + it('holds the transaction lock until the pnpm worker exits', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + const ready = join(root, 'ready') + const release = join(root, 'release') + const blocker = join(root, 'blocking.mjs') + writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) + await worker.applyRelease('1.0.0', hooks()) + const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + try { + await expect.poll(() => existsSync(ready)).toBe(true) + expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) + await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u) + } finally { + writeFileSync(release, 'continue') + await pending + } + expect(existsSync(manager.paths.lock)).toBe(false) }) }) diff --git a/apps/desktop/tests/runtime-fixture.ts b/apps/desktop/tests/runtime-fixture.ts new file mode 100644 index 0000000000..81648dfc5f --- /dev/null +++ b/apps/desktop/tests/runtime-fixture.ts @@ -0,0 +1,42 @@ +/** Temporary materialized packages for Desktop resource and profile behavior tests. */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' + +/** + * Write a package fixture with explicit runtime exports. + * @param modules - Owning node_modules directory. + * @param name - Package name. + * @param fields - Manifest fields. + * @param source - ESM entry contents. + * @returns Installed package directory. + */ +export function writePackage(modules: string, name: string, fields: Record = {}, source = 'export const identity = {}\n'): string { + const path = join(modules, name) + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.js', ...fields })) + writeFileSync(join(path, 'index.js'), source) + return path +} + +/** + * Seal a minimal release containing Host entry files and a shared Cordis package. + * @param root - New runtime directory. + * @param version - Shell and dsh version. + * @param nodeVersion - Bundled Node version used for native rebuild selection. + * @returns Sealed runtime metadata. + */ +export function runtimeFixture(root: string, version = '1.0.0', nodeVersion = '24.17.0'): DesktopRuntimeDescriptor { + const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/cordis'] + for (const name of names) writePackage(join(root, 'node_modules'), name, { version }) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + const path = join(root, 'node_modules', '@deepseek-ai/dsh-desktop-host', file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + } + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n') + return writeDesktopRuntime(root, { schemaVersion: 1, version, nodeVersion, pnpmVersion: '11.7.0', hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }, names) +} diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts new file mode 100644 index 0000000000..4c4dddf52e --- /dev/null +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -0,0 +1,46 @@ +import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-')) + roots.push(root) + runtimeFixture(join(root, 'dsh')) + return root +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('verifies a runtime after relocation without depending on build paths', () => { + const root = fixture() + const before = verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') + cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true }) + expect(desktopRuntimeId(verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) +}) +it.each(['changed', 'extra', 'missing'])('rejects %s runtime bytes', (operation) => { + const dsh = join(fixture(), 'dsh') + if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') + if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') + if (operation === 'missing') rmSync(join(dsh, 'package.json')) + expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/integrity/u) +}) +it('rejects filesystem links and incompatible targets', () => { + const dsh = join(fixture(), 'dsh') + expect(() => verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) + symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') + expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/unsupported filesystem/u) +}) +it('rejects a descriptor that maps a shared package outside node_modules', () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] } + descriptor.sharedPackages[0]!.path = '../outside' + writeFileSync(path, JSON.stringify(descriptor)) + expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package record/u) +}) +it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => { + expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u) +}) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts deleted file mode 100644 index b2f5fc614a..0000000000 --- a/apps/desktop/tests/seed-store.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it } from 'vitest' -import { - archivePnpmStore, - extractPnpmStoreArchives, - mergePnpmStore, - removePnpmProjectRegistrations, - SEED_STORE_ARCHIVE_DIR, - SEED_STORE_ARCHIVE_MANIFEST, -} from '../src/seed-store.ts' - -const temporaryRoots: string[] = [] - -function temporaryRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-store-')) - temporaryRoots.push(root) - return root -} - -function archiveBytes(seed: string): readonly { path: string; body: Buffer }[] { - return [SEED_STORE_ARCHIVE_MANIFEST, ...readdirSync(join(seed, SEED_STORE_ARCHIVE_DIR))] - .map(path => ({ - path, - body: readFileSync(path === SEED_STORE_ARCHIVE_MANIFEST - ? join(seed, path) - : join(seed, SEED_STORE_ARCHIVE_DIR, path)), - })) -} - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop seed store cleanup', () => { - it('removes project registrations without removing package data', () => { - const storeRoot = temporaryRoot() - mkdirSync(join(storeRoot, 'v11', 'projects', 'temporary-project'), { recursive: true }) - mkdirSync(join(storeRoot, 'v12', 'projects'), { recursive: true }) - mkdirSync(join(storeRoot, 'metadata', 'projects'), { recursive: true }) - writeFileSync(join(storeRoot, 'v11', 'package-data'), 'package') - - removePnpmProjectRegistrations(storeRoot) - - expect(existsSync(join(storeRoot, 'v11', 'projects'))).toBe(false) - expect(existsSync(join(storeRoot, 'v12', 'projects'))).toBe(false) - expect(existsSync(join(storeRoot, 'v11', 'package-data'))).toBe(true) - expect(existsSync(join(storeRoot, 'metadata', 'projects'))).toBe(true) - }) -}) - -describe('desktop seed store merge', () => { - it('preserves installed package records while the verified seed replaces matching records and files', () => { - const root = temporaryRoot() - const source = join(root, 'source') - const destination = join(root, 'destination') - for (const store of [source, destination]) { - mkdirSync(join(store, 'v11', 'files'), { recursive: true }) - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - if (store === source) { - insert.run('seed-only', Buffer.from('seed')) - insert.run('shared', Buffer.from('new')) - } else { - insert.run('plugin-only', Buffer.from('plugin')) - insert.run('shared', Buffer.from('old')) - } - database.close() - } - writeFileSync(join(source, 'v11', 'files', 'shared'), 'new') - writeFileSync(join(destination, 'v11', 'files', 'shared'), 'old') - writeFileSync(join(destination, 'v11', 'files', 'plugin'), 'plugin') - - mergePnpmStore(source, destination) - - const database = new DatabaseSync(join(destination, 'v11', 'index.db'), { readOnly: true }) - const records = database.prepare('SELECT key, data FROM package_index ORDER BY key').all() as { - key: string - data: Uint8Array - }[] - database.close() - expect(records.map(record => [record.key, Buffer.from(record.data).toString()])).toEqual([ - ['plugin-only', 'plugin'], - ['seed-only', 'seed'], - ['shared', 'new'], - ]) - expect(readFileSync(join(destination, 'v11', 'files', 'shared'), 'utf8')).toBe('new') - expect(readFileSync(join(destination, 'v11', 'files', 'plugin'), 'utf8')).toBe('plugin') - }) -}) - -describe('desktop seed store archives', () => { - it('extracts package bytes and executable modes without retaining loose seed files', () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const store = join(seed, 'store') - const executable = join(store, 'v10', 'files', 'native-addon') - mkdirSync(join(store, 'v10', 'files'), { recursive: true }) - writeFileSync(executable, 'native') - chmodSync(executable, 0o755) - writeFileSync(join(store, 'v10', 'files', 'package-data'), 'package') - - archivePnpmStore(seed, store) - const destination = join(root, 'extracted') - extractPnpmStoreArchives(seed, destination) - - expect(existsSync(store)).toBe(false) - expect(readFileSync(join(destination, 'v10', 'files', 'package-data'), 'utf8')).toBe('package') - if (process.platform !== 'win32') { - expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111) - } - }) - - it('produces identical shards for identical paths, bytes, and modes', () => { - const root = temporaryRoot() - const seeds = [join(root, 'first'), join(root, 'second')] - for (const [index, seed] of seeds.entries()) { - const store = join(seed, 'store') - mkdirSync(join(store, 'nested'), { recursive: true }) - const paths = index === 0 ? ['alpha', 'nested/beta'] : ['nested/beta', 'alpha'] - for (const path of paths) { - const target = join(store, path) - writeFileSync(target, path) - utimesSync(target, new Date(index * 10_000), new Date(index * 20_000)) - } - archivePnpmStore(seed, store) - } - - const first = archiveBytes(seeds[0] as string) - const second = archiveBytes(seeds[1] as string) - expect(second.map(entry => entry.path)).toEqual(first.map(entry => entry.path)) - expect(second.map(entry => entry.body)).toEqual(first.map(entry => entry.body)) - }) - - it('rejects an archive whose entry count differs from the manifest', () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const store = join(seed, 'store') - mkdirSync(store, { recursive: true }) - writeFileSync(join(store, 'package-data'), 'package') - archivePnpmStore(seed, store) - const manifestPath = join(seed, SEED_STORE_ARCHIVE_MANIFEST) - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - archives: { entries: number }[] - } - const archive = manifest.archives[0] - if (archive === undefined) throw new Error('test seed has no archive') - archive.entries += 1 - writeFileSync(manifestPath, JSON.stringify(manifest)) - - expect(() => { extractPnpmStoreArchives(seed, join(root, 'extracted')) }).toThrow(/unexpected entry count/u) - }) -}) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fd58c7ae00..be8ba71a94 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02 -architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7 +architecture.md: cdd9686261221ffcba7e4991ec4f93bc4e4559f1 +architecture.zh.md: 2cd1b0781e1a56f05d53ba9035542102b15971a9 diff --git a/docs/architecture.md b/docs/architecture.md index a77dfd06c6..cdd9686261 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,9 +48,9 @@ The Python SDK follows the same application architecture. Its runtime wheel pack ## Desktop application -The [Electron desktop application](../apps/desktop/README.md) owns the reserved `$DSH_HOME/profiles/desktop` npm project. Each signed Electron release binds one exact dsh version and carries a first-party offline seed; startup installs that version into the writable profile with the bundled pnpm, while retaining exact desktop-plugin versions from the previous profile. CLI profiles share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules` with Desktop. +The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed application resources. The reserved `$DSH_HOME/profiles/desktop` contains external plugins and links to host-owned packages; compatible upgrades retain plugin files and refresh these links without installing core dependencies. CLI profiles share supported product data under `$DSH_HOME`, while executable packages, plugin activation, lockfiles, and package-manager state remain separate. -Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the installed dsh backend and matching client graph from the reserved profile. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. +Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the bundled dsh backend and matching client graph together with enabled profile plugins. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 83fa958609..2cd1b0781e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,9 +48,9 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI ## 桌面应用 -[Electron 桌面应用](../apps/desktop/README.zh.md)持有保留的 `$DSH_HOME/profiles/desktop` npm 项目。每个签名 Electron 发行版绑定一个确切 dsh 版本并携带第一方离线 seed;启动时通过内置 pnpm 把该版本安装进可写 profile,同时保留旧 profile 中桌面插件的确切版本。CLI profile 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、lockfile 或 `node_modules`。 +[Electron 桌面应用](../apps/desktop/README.zh.md)在签名应用资源中携带精确版本的 dsh 生产运行时。保留的 `$DSH_HOME/profiles/desktop` 保存外部插件和指向宿主拥有包的链接;兼容升级保留插件文件并刷新这些链接,无需安装核心依赖。CLI profile 共享 `$DSH_HOME` 下受支持的产品数据,而可执行包、插件激活、锁文件和包管理器状态保持独立。 -Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 +Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包加载内置 dsh 后端、匹配的客户端图和已启用的 profile 插件。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 ## 核心包 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc0fc3a72..a2c6b9edb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,9 +525,6 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.3.1 - msgpackr: - specifier: 2.0.4 - version: 2.0.4 pnpm: specifier: 11.7.0 version: 11.7.0 @@ -12688,36 +12685,6 @@ packages: resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==} hasBin: true - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} - cpu: [arm64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} - cpu: [x64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} - cpu: [arm64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} - cpu: [arm] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} - cpu: [x64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} - cpu: [x64] - os: [win32] - '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -16331,13 +16298,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.4: - resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} - hasBin: true - - msgpackr@2.0.4: - resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} - multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} @@ -16438,10 +16398,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} - hasBin: true - node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -17149,13 +17105,13 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} - standardwebhooks@1.1.1: - resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -19433,24 +19389,6 @@ snapshots: - supports-color - zod - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -23321,22 +23259,6 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.4: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true - - msgpackr@2.0.4: - optionalDependencies: - msgpackr-extract: 3.0.4 - multistream@4.1.0: dependencies: once: 1.4.0 @@ -23425,11 +23347,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-gyp-build-optional-packages@5.2.2: - dependencies: - detect-libc: 2.1.2 - optional: true - node-gyp@12.4.0: dependencies: env-paths: 2.2.1 From 8874a23b9eaa93d2ed39effdce1dfbef0cf6b376 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 8 Sep 2026 17:44:05 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=E5=8A=A0=E9=80=9F=20mac=20sign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +-- ...-electron-desktop-packaging-and-updates.md | 2 ++ ...ectron-desktop-packaging-and-updates.zh.md | 2 ++ apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 ++ apps/desktop/README.zh.md | 2 ++ apps/desktop/electron-builder.config.mjs | 4 +-- apps/desktop/tests/macos-signature.spec.ts | 17 ++++++++- apps/desktop/tests/macos-signing-walk.spec.ts | 36 +++++++++++++++++++ patches/@electron__osx-sign@1.3.3.patch | 26 ++++++++++++++ pnpm-lock.yaml | 5 +-- pnpm-workspace.yaml | 1 + 12 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/tests/macos-signing-walk.spec.ts create mode 100644 patches/@electron__osx-sign@1.3.3.patch diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index e0081b64f5..fe1bf84c1b 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: fea2c419d73e4eff66de08483733cbc336b6f9af -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 88e2875cd35909c0111926377df83c9fe21841dc +2026-08-25-electron-desktop-packaging-and-updates.md: d7d41aa4d6154f251b56faaa29065db3ee71071f +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 952ed4fc5546524bc088f58e62faccd576194bc7 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index fea2c419d7..d7d41aa4d6 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -85,6 +85,8 @@ Core dsh and the private Desktop Host come only from the signed application reso Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. + Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 88e2875cd3..952ed4fc55 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -85,6 +85,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +[固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 + Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 123b44f866..30b7fcabe7 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: b2a51010dc37bcca7c7846819b72ababdc227496 -README.zh.md: 241a1e1d900fef49042a7526d29fc473ef568088 +README.md: 6ef18d302531beb80877f23d657d8524ff396b2b +README.zh.md: 94669b2697af894f91f33068526a0c61d6ed7a8f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b2a51010dc..6ef18d3025 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -117,6 +117,8 @@ Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOW The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. + ### Windows EV signing Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 241a1e1d90..94669b2697 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -117,6 +117,8 @@ pnpm run upload:mac:arm64 macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 + ### Windows EV 签名 Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入: diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index d082658695..aee7a5b375 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -69,8 +69,8 @@ export function createElectronBuilderConfig( identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, - // Runtime files are signed before their integrity inventory is sealed. - signIgnore: ['/Contents/Resources/dsh(?:/|$)'], + // Native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. + signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'], notarize: true, target: ['dmg', 'zip'], }, diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 09adb91bf1..ada386cc38 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -63,7 +63,7 @@ describe('desktop macOS release signature', () => { identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, forceCodeSigning: true, notarize: true, - signIgnore: ['/Contents/Resources/dsh(?:/|$)'], + signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'], }, dmg: { sign: true, @@ -77,6 +77,21 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('seals PAK resources with their enclosing bundle while signing executable code', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path)) + expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true) + expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true) + for (const path of [ + '/App.app/Contents/Resources/runtime/node/node', + '/App.app/Contents/Resources/runtime/pnpm/addon.node', + '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib', + '/App.app/Contents/Frameworks/Electron.framework', + '/App.app', + ]) expect(ignored(path)).toBe(false) + }) + it('copies the complete runtime despite electron-builder excluding root node_modules', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') diff --git a/apps/desktop/tests/macos-signing-walk.spec.ts b/apps/desktop/tests/macos-signing-walk.spec.ts new file mode 100644 index 0000000000..8dccc4645b --- /dev/null +++ b/apps/desktop/tests/macos-signing-walk.spec.ts @@ -0,0 +1,36 @@ +/** Exercise the installed macOS signer against Framework file and directory aliases. */ + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' + +const builderRequire = createRequire(createRequire(import.meta.url).resolve('app-builder-lib')) +const { walkAsync } = builderRequire(join(dirname(builderRequire.resolve('@electron/osx-sign')), 'util.js')) as { + walkAsync: (path: string) => Promise +} + +// macOS Framework aliases use POSIX file and directory symlinks, unavailable on unprivileged Windows runners. +it.skipIf(process.platform === 'win32')('signs each real Framework file once and retains nested bundle signing', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-signing-walk-')) + try { + const contents = join(root, 'Contents') + const framework = join(contents, 'Frameworks', 'Test.framework') + const version = join(framework, 'Versions', 'A') + const resource = join(version, 'Resources', 'en.lproj', 'locale.pak') + const library = join(version, 'library.dylib') + const helper = join(contents, 'Frameworks', 'Helper.app') + const executable = join(helper, 'Contents', 'MacOS', 'helper') + for (const file of [resource, library, executable]) { + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0])) + } + symlinkSync('A', join(framework, 'Versions', 'Current')) + symlinkSync('Versions/Current/Resources', join(framework, 'Resources')) + symlinkSync('Versions/Current/library.dylib', join(framework, 'library.dylib')) + const files = await walkAsync(contents) + expect(files.sort()).toEqual([resource, library, executable, framework, helper].sort()) + expect(new Set(files.map(file => realpathSync(file))).size).toBe(files.length) + } finally { rmSync(root, { recursive: true, force: true }) } +}) diff --git a/patches/@electron__osx-sign@1.3.3.patch b/patches/@electron__osx-sign@1.3.3.patch new file mode 100644 index 0000000000..791434536c --- /dev/null +++ b/patches/@electron__osx-sign@1.3.3.patch @@ -0,0 +1,26 @@ +diff --git a/dist/cjs/util.js b/dist/cjs/util.js +index 77e64b56872bc65bcb0f60ecfbec84eb3da02c6f..8d5b81eea636c3f1672cfe2c66602078bda1e847 100644 +--- a/dist/cjs/util.js ++++ b/dist/cjs/util.js +@@ -153,7 +153,7 @@ async function walkAsync(dirPath) { + const children = await fs.readdir(dirPath); + return await Promise.all(children.map(async (child) => { + const filePath = path.resolve(dirPath, child); +- const stat = await fs.stat(filePath); ++ const stat = await fs.lstat(filePath); + if (stat.isFile()) { + switch (path.extname(filePath)) { + case '.cstemp': // Temporary file generated from past codesign +diff --git a/dist/esm/util.js b/dist/esm/util.js +index d3f66d54b1a88e7410d78affe17de87302db02a3..e86b066d1d21628908ecc848f5bb29a7598605a7 100644 +--- a/dist/esm/util.js ++++ b/dist/esm/util.js +@@ -117,7 +117,7 @@ export async function walkAsync(dirPath) { + const children = await fs.readdir(dirPath); + return await Promise.all(children.map(async (child) => { + const filePath = path.resolve(dirPath, child); +- const stat = await fs.stat(filePath); ++ const stat = await fs.lstat(filePath); + if (stat.isFile()) { + switch (path.extname(filePath)) { + case '.cstemp': // Temporary file generated from past codesign diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2c6b9edb8..3610e0fe2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@deepseek-ai/schemastery': link:vendor/schemastery patchedDependencies: + '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 '@yao-pkg/pkg@6.21.0': 28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -18637,7 +18638,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/osx-sign@1.3.3': + '@electron/osx-sign@1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861)': dependencies: compare-version: 0.1.2 debug: 4.4.3(supports-color@9.4.0) @@ -20811,7 +20812,7 @@ snapshots: '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 - '@electron/osx-sign': 1.3.3 + '@electron/osx-sign': 1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861) '@electron/rebuild': 4.2.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7cb1746e11..d0b4c0a6e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -87,5 +87,6 @@ minimumReleaseAgeExclude: - '@openai/codex@0.149.1||0.149.1-darwin-arm64||0.149.1-darwin-x64||0.149.1-linux-arm64||0.149.1-linux-x64||0.149.1-win32-arm64||0.149.1-win32-x64' patchedDependencies: + '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch From fc7abdcfb51bba72930920774d995ef90b363852 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 9 Sep 2026 19:18:34 +0800 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20windows=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=8C=85=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 28 ++- ...ectron-desktop-packaging-and-updates.zh.md | 28 ++- ...led-runtime-and-external-plugins.i18n.yaml | 4 +- ...op-bundled-runtime-and-external-plugins.md | 14 +- ...bundled-runtime-and-external-plugins.zh.md | 14 +- ...mmediate-window-and-direct-start.i18n.yaml | 6 + ...sktop-immediate-window-and-direct-start.md | 29 +++ ...op-immediate-window-and-direct-start.zh.md | 29 +++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 44 ++-- apps/desktop/README.zh.md | 44 ++-- apps/desktop/electron-builder.config.d.mts | 5 +- apps/desktop/electron-builder.config.mjs | 25 ++- apps/desktop/package.json | 1 + apps/desktop/renderer/plugin-manager.js | 4 +- apps/desktop/renderer/startup.css | 16 ++ apps/desktop/renderer/startup.html | 23 ++ apps/desktop/renderer/startup.js | 37 ++++ apps/desktop/scripts/installer.nsh | 17 ++ apps/desktop/scripts/package-target.ts | 32 ++- apps/desktop/scripts/prepare-dsh.ts | 16 +- apps/desktop/scripts/runtime-file-policy.ts | 40 ++++ apps/desktop/src/backend-controller.ts | 146 +++++++++++++ apps/desktop/src/host-process.ts | 13 +- apps/desktop/src/ipc.ts | 11 +- apps/desktop/src/locale.ts | 8 + apps/desktop/src/main.ts | 192 ++++++++--------- apps/desktop/src/owned-directory.ts | 26 +++ apps/desktop/src/preload-app.ts | 24 ++- apps/desktop/src/preload.ts | 6 + apps/desktop/src/profile-packages.ts | 10 +- apps/desktop/src/project-manager.ts | 33 +-- apps/desktop/src/runtime-tree.ts | 96 +++++++-- apps/desktop/tests/backend-controller.spec.ts | 170 +++++++++++++++ .../fixtures/installer-cleanup-smoke.nsi | 70 ++++++ .../installer-write-failure-smoke.nsi | 55 +++++ .../tests/fixtures/owned-directory-smoke.mjs | 29 +++ .../tests/fixtures/runtime-payload-smoke.mjs | 138 ++++++++++++ apps/desktop/tests/host-process.spec.ts | 26 ++- apps/desktop/tests/macos-signature.spec.ts | 27 ++- apps/desktop/tests/main-startup.spec.ts | 200 ++++++++++++++++++ apps/desktop/tests/owned-directory.spec.ts | 53 +++++ apps/desktop/tests/package-target.spec.ts | 40 ++++ apps/desktop/tests/plugin-manager.spec.ts | 2 +- apps/desktop/tests/plugin-pnpm.spec.ts | 2 +- apps/desktop/tests/preload-app.spec.ts | 37 ++++ apps/desktop/tests/project-manager.spec.ts | 35 ++- .../desktop/tests/runtime-file-policy.spec.ts | 81 +++++++ .../tests/runtime-tree-read-failure.spec.ts | 49 +++++ apps/desktop/tests/runtime-tree.spec.ts | 52 +++-- apps/desktop/tests/startup-renderer.spec.ts | 134 ++++++++++++ apps/desktop/tsdown.config.ts | 13 +- package.json | 1 + 54 files changed, 1984 insertions(+), 259 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md create mode 100644 apps/desktop/renderer/startup.css create mode 100644 apps/desktop/renderer/startup.html create mode 100644 apps/desktop/renderer/startup.js create mode 100644 apps/desktop/scripts/installer.nsh create mode 100644 apps/desktop/scripts/runtime-file-policy.ts create mode 100644 apps/desktop/src/backend-controller.ts create mode 100644 apps/desktop/src/owned-directory.ts create mode 100644 apps/desktop/tests/backend-controller.spec.ts create mode 100644 apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi create mode 100644 apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi create mode 100644 apps/desktop/tests/fixtures/owned-directory-smoke.mjs create mode 100644 apps/desktop/tests/fixtures/runtime-payload-smoke.mjs create mode 100644 apps/desktop/tests/main-startup.spec.ts create mode 100644 apps/desktop/tests/owned-directory.spec.ts create mode 100644 apps/desktop/tests/preload-app.spec.ts create mode 100644 apps/desktop/tests/runtime-file-policy.spec.ts create mode 100644 apps/desktop/tests/runtime-tree-read-failure.spec.ts create mode 100644 apps/desktop/tests/startup-renderer.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index fe1bf84c1b..d182ff8d4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: d7d41aa4d6154f251b56faaa29065db3ee71071f -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 952ed4fc5546524bc088f58e62faccd576194bc7 +2026-08-25-electron-desktop-packaging-and-updates.md: f6aa73d6e708db04c6978690b8fc2b7eb70f270b +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 00c1346ab9b0bca1f704d74e7792acd03c5df9bf diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index d7d41aa4d6..f6aa73d6e7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -65,30 +65,38 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ## Installation and resolution -The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. +The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Activation stops the active backend, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. -Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). A desktop plugin contributing `dsh.client` code enters the client graph only after the complete profile passes health checking. +Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts. ## Updates and recovery Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release. -Before opening the product window, startup validates the current bundled runtime and reconciles external plugins according to the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Backend failure opens the Electron-owned recovery window instead of booting a mismatched dsh release. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. ## Security and release policy -Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation. +Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, and user-only directory permissions are required before activation. -Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification. + +Local Windows installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without an EV token while preventing local test output from qualifying for release upload. + +NSIS extracts into its private `7z-out` directory before copying files into the application directory. Its default exit cleanup can overlap the backend's file reads after Finish launches the application. The [installer hook](../../../../apps/desktop/scripts/installer.nsh) removes only that extraction directory during `customInstall`, before interactive and silent launch branches. It preserves package archives, plugin DLLs, rollback directories, registers, and error status; the [native cleanup smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) checks these constraints. Moving cleanup into installation does not remove filesystem work, so total installation time and Finish-to-window time require separate measurements. + +Direct `Nsis7z::Extract` into the application directory is not enabled. A native [locked-file probe](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi) leaves an old locked file beside a new asset while reporting no error; the staged `CopyFiles` operation sets the error flag for the same failed replacement. An unchanged 737,557,488-byte payload on Windows took 172.625 seconds through extraction, copying, and cleanup versus 28.031 seconds through direct extraction, but that single sample per path does not justify losing failure detection. The clocks exclude registry changes, old-version removal, and post-extraction verification; system caches were not cleared. Desktop-specific payload filtering reduces the copied file set while retaining the installer's replacement-error handling. This handling is not a promise of complete installation rollback. + Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas. @@ -135,8 +143,8 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. - The backend and browser application cannot mutate desktop packages. - npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`. -- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens. -- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart. +- The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads. +- Failed installation, backend activation, or update leaves the current profile usable or restores `rollback/profile` after restart. - One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart. - Shared `.dsh` data rejects incompatible readers before migration or mutation. - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. @@ -149,10 +157,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | Decision | Recommendation | |---|---| -| First launch | Validate bundled production packages and create profile links without installing core dependencies | +| First launch | Check bundled release metadata and create profile links without installing core dependencies | | Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | -| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy | +| Activation | Staging project, journaled directory replacement, actual Host startup, one rollback copy | | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | | Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation | @@ -160,7 +168,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships. -Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one. +Updating the bound dsh can invalidate plugin peer dependencies or native modules. Dependency validation rejects incompatible metadata before replacement; actual Host startup can fail after replacement and trigger profile rollback. An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 952ed4fc55..00c1346ab9 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -65,30 +65,38 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] ## 安装与解析 -安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 +安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。激活过程停止活跃后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 -核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。提供 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才会进入客户端依赖图。 +核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。 ## 更新与恢复 Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 -打开产品窗口前,启动过程会验证当前内置运行时,并根据[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)协调外部插件。后端失败会打开 Electron 拥有的恢复窗口,不会启动版本不匹配的 dsh。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。 `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 ## 安全与发布策略 -核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查。 +核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限。 -Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。 + +本地 Windows 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有 EV Token 时诊断安装问题,也能防止本地测试产物通过发布上传校验。 + +NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录。Finish 启动应用后,默认退出清理可能与后端的文件读取重叠。[安装器 hook](../../../../apps/desktop/scripts/installer.nsh) 在 `customInstall` 阶段仅删除该解压目录,早于交互和静默启动分支。它保留包归档、插件 DLL、回滚目录、寄存器和错误状态;[原生清理 smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) 检查这些约束。把清理移入安装阶段并不会减少文件系统工作,因此必须分别测量安装总耗时与点击 Finish 到窗口出现的耗时。 + +安装器不开启直接向应用目录执行 `Nsis7z::Extract`。原生[文件占用探针](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi)会在未报错的情况下留下被占用的旧文件和新资源;暂存后执行的 `CopyFiles` 在相同替换失败时会设置错误标志。Windows 上同一份 737,557,488 字节载荷经过解压、复制和清理耗时 172.625 秒,直接解压耗时 28.031 秒,但每条路径的单次样本不足以支持放弃失败检测。计时不包括注册表修改、旧版删除及解压后的验证,也没有清空系统缓存。桌面专用载荷过滤减少需要复制的文件,同时保留安装器的替换错误处理。这种处理并不承诺完整的安装回滚。 + 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 @@ -135,8 +143,8 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 - 后端与浏览器应用不能修改桌面包。 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 -- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 -- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 +- 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 +- 安装、后端激活或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 @@ -149,10 +157,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool | 决策 | 建议 | |---|---| -| 首次启动 | 验证内置生产包并创建 profile 链接,不安装核心依赖 | +| 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 | | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | -| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | +| 激活 | 暂存项目、记录式目录替换、实际 Host 启动和一个回滚副本 | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | @@ -160,7 +168,7 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。 -更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。 +更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。依赖验证在替换前拒绝不兼容的元数据;实际 Host 启动可能在替换后失败,并触发 profile 回滚。 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 9729df077f..8c5be1e7e1 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 8278107fb3c37aca909541886d9379a1bfd64bac -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b29eb1e6cbe19d4a5b4ea23a5d6159cfd3973bcd +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a662af570c017d6fd3d38cf066c0a02e0049a84f +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 6284949f6747cbe47a5de149ff598354c9b9cc58 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index 8278107fb3..a662af570c 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -20,6 +20,10 @@ Desktop has not been released. This is its first installation format; there are The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization. +The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability. + +Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. + Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations. External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient. @@ -28,16 +32,20 @@ The profile manifest records exact installed plugin dependencies separately from ## Transactions and upgrades -First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, validates enabled peers, and boots the staged backend. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. +First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, and validates enabled peers. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. + +Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary staging and backend replacement. Transaction cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction. The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes. -An Electron-owned recovery window remains available when the backend cannot start. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window, replacing staged backend probes. Users can open plugin management to update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. ## Alternatives considered +Full runtime verification belongs to packaging. Startup reads the descriptor, validates release and target compatibility, checks shared manifests and required Host entries, and uses the recorded runtime identity for profile reuse. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. + - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. - **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target. @@ -46,6 +54,6 @@ An Electron-owned recovery window remains available when the backend cannot star ## Consequences -Core package installation is absent from first launch and compatible upgrades. Runtime integrity verification and a complete backend health check still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. +Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index b29eb1e6cb..6284949f67 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -20,6 +20,10 @@ Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 se 资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。 +[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。 + +包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 + dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。 外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。 @@ -28,16 +32,20 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 事务与升级 -首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,验证已启用的 peer,并启动 staging 后端。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 +首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,并验证已启用的 peer。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 + +共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 staging 和后端替换。事务清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。 事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。 -后端无法启动时,Electron 拥有的恢复窗口仍可使用。用户可以更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host 和主窗口恢复,取代 staging 后端探针。用户可以打开插件管理,更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 ## 考虑过的替代方案 +完整运行时验证属于打包流程。启动读取描述文件,验证发布版本与目标平台的兼容性,检查共享包 manifest 和必要的 Host 入口,并使用记录的运行时身份复用 profile。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 + - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。 @@ -46,6 +54,6 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 影响 -首次启动和兼容升级不安装核心包。运行时完整性验证和完整后端健康检查仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 +首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 [Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml new file mode 100644 index 0000000000..9a562164c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-desktop-immediate-window-and-direct-start.md +2026-09-09-desktop-immediate-window-and-direct-start.md: 19443bb22e5fa3e50ce027b474cd439928db9992 +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 0d79638d331bb8b66a80f37f532b36ebb1e65316 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md new file mode 100644 index 0000000000..19443bb22e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -0,0 +1,29 @@ +# Agent Note: Show the Desktop window before starting the Host + +Status: implemented + +English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) + +## Problem + +Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process. + +## Decision + +Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit. + +Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes. + +This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. + +## Alternatives considered + +**Keep a complete staged health check.** It can reject a startup failure before replacing the active profile, but executes plugin initialization twice and cannot guarantee that the subsequent serving process will start. The actual startup result and journaled rollback provide recovery without the extra probe. + +**Keep the main window hidden until readiness.** This avoids presenting a loading page but gives users no visible progress or interaction while the backend loads. A shell-owned page can remain available when Host startup fails. + +## Consequences + +Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Activation can fail after profile replacement; the journal retains recovery for profile files. + +Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md new file mode 100644 index 0000000000..0d79638d33 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Show the Desktop window before starting the Host + +Status: implemented + +[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 + +## 问题 + +等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。 + +## 决策 + +Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断、重试和插件管理操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 + +profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。 + +本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。 + +## 考虑过的替代方案 + +**保留完整 staging 健康检查。** 它可以在替换活动 profile 前拒绝启动失败,但会执行两次插件初始化,也不能保证后续服务进程能够启动。实际启动结果与记录式回滚无需额外探针即可提供恢复能力。 + +**在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。 + +## 后果 + +产品 UI 可用前,用户就能看到启动进度并从失败中恢复。窗口可响应并不表示后端已就绪,启动延迟仍需测量安装产物。激活可能在替换 profile 后失败;事务日志保留 profile 文件的恢复能力。 + +验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 30b7fcabe7..c2b596749a 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: 6ef18d302531beb80877f23d657d8524ff396b2b -README.zh.md: 94669b2697af894f91f33068526a0c61d6ed7a8f +README.md: 86ef7d9eb63f3b7aee89a86b5c3a4866af034ac5 +README.zh.md: ea8976b880a5d5a3df5932c700237505d5f19d5a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6ef18d3025..86ef7d9eb6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,7 +14,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no | Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. | -| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. | +| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes prepare in staging and start the actual backend after profile replacement; a journal and one rollback profile cover failed or interrupted activation. | | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions. @@ -23,23 +23,23 @@ The [Electron packaging and update Agent Note](../../.agents/notes/implemented/a Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The local startup page exposes startup status, retry, and plugin-management actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. +Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. ### Runtime and plugin activation -The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup verifies the tree before creating host links. Core packages are never copied into profile storage or installed by pnpm at first launch. +The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup checks release metadata, shared package manifests, and required Host entry files without enumerating or hashing the runtime tree. Full file integrity verification runs during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. -1. A fresh profile creates its manifest and shared package links, then boots and stops the complete backend before activation. -2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, checks enabled plugins’ peer requirements, and runs the same backend health check. Plugin versions and lockfile remain unchanged; pnpm does not run. +1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once. +2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, and checks enabled plugins’ peer requirements. Plugin versions and lockfile remain unchanged; pnpm does not run. 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. -5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. A failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. +5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. The actual backend starts after replacement; failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. -The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. +The loading page does not depend on the Host. Startup failures appear in the same main window with retry and plugin-management actions. Closing during loading waits for the pending child to exit. The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. -Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; hardlinks are unsuitable because they cannot represent package directories and would retain old file content after application replacement. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. +Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. ## Develop @@ -90,6 +90,12 @@ The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on In Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. +### 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/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 packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) exercises the resulting native modules and HTML converter under the bundled Node. + ### Upload updates `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`, while the production origin remains `https://download.deepseek.com`. Upload additionally requires the selected deployment's COS bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. The target path is `_/harness/desktop/stable//`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`. @@ -119,8 +125,22 @@ The macOS configuration uses the required release environment instead of accepti macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. +### Unsigned Windows test installer + +On Windows x64, use the complete unsigned packaging command for local installation testing: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements. + ### Windows EV signing +Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. + +NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. + Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target: ```powershell @@ -131,7 +151,7 @@ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' pnpm run package:desktop:win:x64 ``` -Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. +Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. @@ -156,9 +176,9 @@ An unpacked artifact contains Electron, the materialized dsh production tree, up ## Updates -A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound runtime before reopening the product window. +A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch displays the local loading page while reconciling the version-bound runtime. -Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. +Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. ## Low-level development overrides diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 94669b2697..ea8976b880 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,7 +14,7 @@ | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 | | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 | -| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 | +| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先在 staging 准备,替换 profile 后启动实际后端;失败或中断的激活由事务日志和一个 rollback profile 恢复。 | | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。 @@ -23,23 +23,23 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 +本地启动页提供启动状态、重试和插件管理操作;加载后的 dsh 渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 -Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 +Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 ### 运行时与插件激活 -签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动会先验证依赖树,再创建宿主链接。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 +签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动检查发布元数据、共享包 manifest 和必要的 Host 入口文件,不枚举运行时目录树或计算其文件哈希。完整的文件完整性验证在打包时执行。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 -1. 新 profile 创建 manifest 和共享包链接,然后启动并停止完整后端,通过后才激活。 -2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求,并执行相同的后端健康检查。插件版本和锁文件保持不变;不运行 pnpm。 +1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest 和共享包链接,然后激活并启动一次实际后端。 +2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,并检查已启用插件的 peer 要求。插件版本和锁文件保持不变;不运行 pnpm。 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 -5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 +5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。替换后启动实际后端;激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 -后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 +加载页不依赖 Host。启动失败在同一个主窗口显示,并提供重试和插件管理操作。加载期间关闭应用会等待正在启动的子进程退出。后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 -包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;硬链接无法表示包目录,而且应用替换后仍会保留旧文件内容,因此不适合此用途。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 +包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 ## 开发 @@ -90,6 +90,12 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 +### 运行时文件筛选 + +生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。 + +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)使用内置 Node 验证过滤后的原生模块和 HTML 转换器。 + ### 上传更新 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包时写入的更新 URL 与后续 COS 上传目标,可取 `test` 或 `production`;未设置时使用 `test`。测试打包必须通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin,生产 origin 仍为 `https://download.deepseek.com`。上传还必须通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选环境的 COS bucket。目标路径为 `_/harness/desktop/stable//`,其中 `target` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。 @@ -119,8 +125,22 @@ macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 +### 未签名 Windows 测试安装包 + +在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。 + ### Windows EV 签名 +Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 + +NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 + Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入: ```powershell @@ -131,7 +151,7 @@ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' pnpm run package:desktop:win:x64 ``` -打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。 +打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 @@ -156,9 +176,9 @@ pnpm run prepare:desktop ## 更新 -打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的运行时,再重新打开产品窗口。 +打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动在显示本地加载页的同时校准版本绑定的运行时。 -Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 +签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 ## 底层开发覆盖项 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 760f8cb8a1..8a78f3a68a 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -19,8 +19,11 @@ export interface DesktopElectronBuilderConfig { readonly sign: boolean readonly writeUpdateInfo: boolean } + readonly nsis: { + readonly include: string + } readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise | undefined - readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] + readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null } /** diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index aee7a5b375..4dfb363693 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, @@ -11,7 +12,7 @@ import { installWindowsNsisBootstrapSigner, } from './scripts/windows-sign.mjs' import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs' -import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs' +import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs' /** * Create electron-builder configuration from one release environment. @@ -29,11 +30,16 @@ export function createElectronBuilderConfig( const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM const resolvedPlatform = targetPlatform ?? hostPlatform const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch + if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) { + throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') + } + const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' + if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows') const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') const packagesWindows = targetPlatform === 'win32' const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined if (packagesMacOS) resolveMacOSNotarizationEnvironment(env) - const windowsSigner = packagesWindows + const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL, @@ -44,13 +50,13 @@ export function createElectronBuilderConfig( if (windowsSigner !== undefined) { installWindowsNsisBootstrapSigner({ sign: windowsSigner }) } - const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) - const buildPaths = desktopTargetBuildPaths(update.target) + const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) + const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) return { appId, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', - directories: { output: buildPaths.artifacts }, + directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, asar: true, files: [ 'lib/*.js', @@ -80,13 +86,13 @@ export function createElectronBuilderConfig( }, afterPack: async context => { const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') - verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'), + await verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'), context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) }, afterSign: async context => { if (context.electronPlatformName !== 'darwin') return const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') - verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'), + await verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'), context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch }) verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) }, @@ -99,7 +105,7 @@ export function createElectronBuilderConfig( ) }, win: { - forceCodeSigning: true, + forceCodeSigning: !unsigned, signtoolOptions: { sign: windowsSigner, signingHashAlgorithms: ['sha256'], @@ -111,11 +117,12 @@ export function createElectronBuilderConfig( target: ['AppImage'], }, nsis: { + include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)), oneClick: false, allowToChangeInstallationDirectory: true, differentialPackage: true, }, - publish: [{ provider: 'generic', url: update.publicUrl }], + publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }], } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8d38981c5..240644a84f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "package:mac:x64": "tsx scripts/package-target.ts mac-x64", "package:mac:x64:dir": "tsx scripts/package-target.ts mac-x64 --dir", "package:win:x64": "tsx scripts/package-target.ts win-x64", + "package:win:x64:unsigned": "tsx scripts/package-target.ts win-x64 --unsigned", "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir", "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64", "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64", diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js index 6599c5b9d5..c87460a49f 100644 --- a/apps/desktop/renderer/plugin-manager.js +++ b/apps/desktop/renderer/plugin-manager.js @@ -32,8 +32,8 @@ async function main() { async function render() { const backend = await api.backend.status() - document.querySelector('#recovery').hidden = backend.ready - document.querySelector('#startup-error').textContent = backend.error ?? '' + document.querySelector('#recovery').hidden = backend.phase !== 'error' + document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : '' const plugins = await api.plugins.list() list.replaceChildren(...plugins.map(plugin => { const item = document.createElement('li') diff --git a/apps/desktop/renderer/startup.css b/apps/desktop/renderer/startup.css new file mode 100644 index 0000000000..a15f246ee3 --- /dev/null +++ b/apps/desktop/renderer/startup.css @@ -0,0 +1,16 @@ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; color: #202124; background: #fafafa; } +body { margin: 0; min-height: 100vh; display: grid; place-items: center; } +main { width: min(560px, calc(100vw - 64px)); padding: 40px 0; text-align: center; } +h1 { margin: 24px 0 12px; font-size: 22px; font-weight: 600; } +p { color: #666; line-height: 1.6; } +#spinner { width: 32px; height: 32px; margin: auto; border: 3px solid #dedee3; border-top-color: #4d6bfe; border-radius: 50%; animation: spin 0.9s linear infinite; } +#error { padding: 16px; border: 1px solid #e0e0e5; border-radius: 10px; max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; text-align: left; font: 13px/1.6 ui-monospace, monospace; } +#actions { margin-top: 24px; } +button { margin: 4px; padding: 10px 18px; border: 1px solid transparent; border-radius: 8px; color: #fff; background: #4d6bfe; font: inherit; cursor: pointer; } +button.secondary { color: inherit; background: transparent; border-color: #c8c8d0; } +button:disabled { opacity: 0.5; cursor: default; } +button:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; } +[hidden] { display: none !important; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-color-scheme: dark) { :root { color: #ededf0; background: #171719; } p { color: #aaaab3; } #error { border-color: #38383f; } #spinner { border-color: #38383f; border-top-color: #6a85ff; } } +@media (prefers-reduced-motion: reduce) { #spinner { animation: none; } } diff --git a/apps/desktop/renderer/startup.html b/apps/desktop/renderer/startup.html new file mode 100644 index 0000000000..e0980a5b49 --- /dev/null +++ b/apps/desktop/renderer/startup.html @@ -0,0 +1,23 @@ + + + + + + + + + + +
+ +

+

+ + +
+ + + diff --git a/apps/desktop/renderer/startup.js b/apps/desktop/renderer/startup.js new file mode 100644 index 0000000000..ce8c64361a --- /dev/null +++ b/apps/desktop/renderer/startup.js @@ -0,0 +1,37 @@ +const api = window.dshDesktop + +async function main() { + const { id, messages } = await api.locale() + document.documentElement.lang = id + document.querySelector('#page-title').textContent = messages.startupLoading + document.querySelector('#retry').textContent = messages.retry + document.querySelector('#plugins').textContent = messages.managePlugins + function render(state) { + const failed = state.phase === 'error' + document.querySelector('main').setAttribute('aria-busy', String(!failed)) + document.querySelector('#spinner').hidden = failed + document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading + document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription + document.querySelector('#error').hidden = !failed + document.querySelector('#error').textContent = failed ? state.message : '' + document.querySelector('#actions').hidden = !failed + document.querySelector('#retry').disabled = !failed + document.querySelector('#plugins').disabled = !failed + } + let changed = false + const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) }) + window.addEventListener('pagehide', unsubscribe, { once: true }) + const initial = await api.backend.status() + if (!changed) render(initial) + document.querySelector('#retry').addEventListener('click', async () => { + render({ phase: 'starting' }) + try { await api.backend.retry() } + catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } + }) + document.querySelector('#plugins').addEventListener('click', async () => { + try { await api.openPlugins() } + catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } + }) +} + +void main() diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh new file mode 100644 index 0000000000..3bd8622c72 --- /dev/null +++ b/apps/desktop/scripts/installer.nsh @@ -0,0 +1,17 @@ +!include "LogicLib.nsh" + +!macro customInstall + Push $0 + StrCpy $0 0 + ${If} ${Errors} + StrCpy $0 1 + ${EndIf} + ; Finish can launch the app while NSIS removes its remaining plugin directory. + RMDir /r "$PLUGINSDIR\7z-out" + ${If} $0 == 1 + SetErrors + ${Else} + ClearErrors + ${EndIf} + Pop $0 +!macroend diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c5b0bc2667..cdbe8d167a 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -72,6 +72,25 @@ export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv) .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX))) } +/** + * Select signing and NSIS-compatible archive filters for electron-builder. + * @param environment - Target packaging environment. + * @param unsigned - Whether to create a local unsigned Windows artifact. + * @returns Packaging environment without certificate inputs for unsigned builds. + */ +export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv { + const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' } + // The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries. + if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ' + if (!unsigned) return selected + return { + ...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected)) + .filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))), + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + DSH_DESKTOP_UNSIGNED: '1', + } +} + /** * Remove upload-only COS credentials from every packaging subprocess. * @param environment - Packaging command environment. @@ -152,6 +171,7 @@ interface DesktopPackageInvocation { readonly target: DesktopPackageTarget readonly directory: boolean readonly prepareOnly: boolean + readonly unsigned: boolean } function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { @@ -178,14 +198,18 @@ export function parseDesktopPackageInvocation( options: { dir: { type: 'boolean', default: false }, 'prepare-only': { type: 'boolean', default: false }, + unsigned: { type: 'boolean', default: false }, }, }) if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) + if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64') + if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') return { target: resolveDesktopPackageTarget(name, hostPlatform, hostArch), directory: values.dir, prepareOnly: values['prepare-only'], + unsigned: values.unsigned, } } @@ -240,7 +264,7 @@ async function main(): Promise { const { target } = invocation const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) - if (!invocation.prepareOnly) { + if (!invocation.prepareOnly && !invocation.unsigned) { rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } @@ -250,9 +274,9 @@ async function main(): Promise { DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } - const electronBuilderEnv = { ...targetEnv } + const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned) for (const name of WINDOWS_SIGNING_ENV_NAMES) { - if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] + if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] } await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) @@ -279,7 +303,7 @@ async function main(): Promise { await runPnpm(['run', 'prepare:dsh'], targetEnv) if (invocation.prepareOnly) return await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) - if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) + if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index d030a05e1a..eb223fbc7e 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, relative, resolve } from 'node:path' import { createRuntimeProjectMetadata } from '../src/project-manager.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' @@ -25,6 +25,7 @@ import { signMacOSRuntime, } from './macos-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -115,10 +116,13 @@ async function main(): Promise { ) await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']) const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) + const targetName = resolveDesktopBuildTarget() + const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + const modules = join(BUILD_ROOT, 'node_modules') mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) - cpSync(join(BUILD_ROOT, 'node_modules'), join(DSH_OUTPUT_ROOT, 'node_modules'), { + cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), { recursive: true, dereference: true, - filter: source => !source.split(/[\\/]/u).some(part => part === '.bin' || part === '.pnpm' || part === '.modules.yaml' || part === '.pnpm-workspace-state-v1.json'), + filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined, }) writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', @@ -132,12 +136,10 @@ async function main(): Promise { if (process.platform === 'darwin') { await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) } - const targetName = resolveDesktopBuildTarget() - const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) - const descriptor = verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) - verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) } catch (error) { rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) throw error diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts new file mode 100644 index 0000000000..9e6722009d --- /dev/null +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -0,0 +1,40 @@ +/** Desktop-only omissions from already installed production npm packages. */ + +/** + * Identify build and diagnostic files omitted from the immutable Desktop runtime. + * Unrecognized assets and target runtime binaries are retained. Paths name the copied + * node_modules tree, including nested package containers. + * @param path - Path relative to the production node_modules directory. + * @param target - Platform and architecture of the bundled Node executable. + * @returns Omission reason, or undefined when the entry must be copied. + */ +export function desktopRuntimeFileExclusion( + path: string, target: { platform: NodeJS.Platform; arch: string }, +): string | undefined { + const parts = path.split(/[\\/]/u) + if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { + return 'package-manager metadata' + } + const file = parts.at(-1) ?? '' + if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map' + if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration' + if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache' + const packageParts = parts.slice(parts.lastIndexOf('node_modules') + 1) + const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1 + const name = packageParts.slice(0, nameParts).join('/') + const entry = packageParts.slice(nameParts).join('/') + if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { + return 'fs-ext compiler output' + } + if (name === 'fs-ext' && /^build\/(?:binding\.sln|config\.gypi|fs_ext\.vcxproj(?:\.filters)?)$/u.test(entry)) { + return 'fs-ext build configuration' + } + if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures' + if (name === 'node-pty' && entry.startsWith('prebuilds/')) { + const platform = packageParts[nameParts + 1] + if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform' + if (file.endsWith('.pdb')) return 'node-pty debug symbols' + } + if (name === '@koromix/koffi-win32-x64' && entry === 'win32_x64/koffi.lib') return 'Koffi import library' + return undefined +} diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts new file mode 100644 index 0000000000..03c49369ab --- /dev/null +++ b/apps/desktop/src/backend-controller.ts @@ -0,0 +1,146 @@ +/** Owns one backend startup and its quiescent teardown independently of windows. */ + +/** Backend availability presented by the desktop window. */ +export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string } + +/** Child lifecycle owned by the desktop backend controller. */ +export interface DesktopBackendHost { + /** @returns Readiness after the child accepts application requests. */ + start(): Promise + /** @returns Completion of child exit. */ + stop(): Promise +} + +interface Attempt { + cancelled: boolean + failure?: Error + host?: Host + cleanup?: Promise +} + +/** Serializes retries and prevents children from outliving a closed window. */ +export class DesktopBackendController { + private current: DesktopBackendState = { phase: 'starting' } + private attempt: Attempt | undefined + private pending: Promise | undefined + private stopping: Promise | undefined + private closed = false + + /** + * @param createHost - Allocate a child and route its fatal failures to the supplied callback. + * @param publish - Receive availability changes until the controller closes. + */ + constructor( + private readonly createHost: (onFailure: (error: Error) => void) => Host, + private readonly publish: (state: DesktopBackendState) => void, + ) {} + + /** Current availability, including the last startup or child failure. */ + get state(): DesktopBackendState { return this.current } + + /** Child available to application requests; absent during startup and teardown. */ + get host(): Host | undefined { return !this.closed && !this.attempt?.cancelled && this.current.phase === 'ready' ? this.attempt?.host : undefined } + + /** + * Prepare the profile and start one child; concurrent callers share the attempt. + * @param prepare - Profile preparation that must finish before spawning. + * @returns Completion of startup, rejecting on preparation, startup, or cleanup failure. + */ + start(prepare: () => Promise): Promise { + if (this.closed) return Promise.reject(new Error('desktop backend is closed')) + if (this.stopping !== undefined) return Promise.reject(new Error('desktop backend is stopping')) + if (this.pending !== undefined) return this.pending + if (this.current.phase === 'ready') return Promise.resolve() + const previous = this.attempt + const attempt: Attempt = { cancelled: false, ...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }) } + this.attempt = attempt + this.update({ phase: 'starting' }) + const pending = Promise.resolve().then(async () => { + try { + await previous?.cleanup + delete attempt.cleanup + if (attempt.cancelled) return + await prepare() + // stop() can cancel this attempt while preparation is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (attempt.cancelled) return + const host = this.createHost((error) => { this.failed(attempt, error) }) + attempt.host = host + await host.start() + if (attempt.failure !== undefined) throw attempt.failure + // stop() can cancel this attempt while the child starts. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!attempt.cancelled) this.update({ phase: 'ready' }) + } catch (error) { + const cancelled = attempt.cancelled + attempt.cancelled = true + let failure = error + try { await this.cleanup(attempt) } catch (cleanupError) { + if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') + } + if (!cancelled) this.update({ phase: 'error', message: failure instanceof Error ? failure.message : String(failure) }) + throw failure + } + }).finally(() => { if (this.pending === pending) this.pending = undefined }) + this.pending = pending + return pending + } + + /** + * Stop pending preparation and the child before allowing another start. + * @returns Completion of pending work and child exit; rejects if cleanup fails. + */ + stop(): Promise { + if (this.stopping !== undefined) return this.stopping + const attempt = this.attempt + if (attempt !== undefined) attempt.cancelled = true + if (!this.closed) this.update({ phase: 'starting' }) + const pending = this.pending + const stopping = Promise.allSettled([ + attempt === undefined ? Promise.resolve() : this.cleanup(attempt), + pending, + ]).then((results) => { + const cleanup = results[0] + if (cleanup.status === 'rejected') throw cleanup.reason + if (this.attempt === attempt) this.attempt = undefined + }).finally(() => { if (this.stopping === stopping) this.stopping = undefined }) + this.stopping = stopping + return stopping + } + + /** + * Permanently prevent startup and suppress further availability notifications. + * @returns Completion of pending work and child exit; rejects if cleanup fails. + */ + close(): Promise { + this.closed = true + return this.stop() + } + + private cleanup(attempt: Attempt): Promise { + if (attempt.cleanup === undefined) { + attempt.cleanup = Promise.resolve().then(async () => { await attempt.host?.stop() }) + } + return attempt.cleanup + } + + private failed(attempt: Attempt, error: Error): void { + if (this.attempt !== attempt || attempt.cancelled) return + attempt.failure = error + if (this.current.phase !== 'ready') return + attempt.cancelled = true + const cleanup = this.cleanup(attempt) + this.update({ phase: 'error', message: error.message }) + void cleanup.catch((cleanupError: unknown) => { + if (this.attempt === attempt) this.update({ phase: 'error', message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) }) + }) + } + + private update(state: DesktopBackendState): void { + if (this.closed) return + this.current = state + try { this.publish(state) } catch (error) { + console.error('desktop backend state listener failed', error) + } + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 20c59cf7e1..7442c71172 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -83,6 +83,7 @@ export class DesktopHostProcess { }) private exitPromise: Promise | undefined private stderr = '' + private failureReported = false /** * @param node - absolute bundled upstream Node.js executable. @@ -90,6 +91,7 @@ export class DesktopHostProcess { * @param projectDir - active or staged desktop plugin profile. * @param inspectPort - optional loopback inspector port for workspace development. * @param environment - Child environment; runtime and package-manager overrides are removed. + * @param onFailure - Receives the first fatal child or transport failure, including after readiness. */ constructor( private readonly node: string, @@ -97,6 +99,7 @@ export class DesktopHostProcess { private readonly projectDir: string, private readonly inspectPort?: number, private readonly environment: NodeJS.ProcessEnv = process.env, + private readonly onFailure?: (error: Error) => void, ) {} /** Start the child once and resolve only after its complete composition is active. */ @@ -149,7 +152,7 @@ export class DesktopHostProcess { }) child.once('error', (error) => { this.fail(error) }) this.exitPromise = new Promise((resolve) => { - child.once('exit', (code) => { + child.once('close', (code) => { const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}` if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`)) else this.fail(new Error(`dsh desktop host stopped${suffix}`)) @@ -278,7 +281,7 @@ export class DesktopHostProcess { private send(message: DesktopHostCommand): void { const child = this.child if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable') - child.send(message) + child.send(message, (error) => { if (error !== null) this.fail(error) }) } private acceptResponseBytes(chunk: Buffer): void { @@ -405,6 +408,12 @@ export class DesktopHostProcess { private fail(error: Error): void { this.readyReject(error) + if (!this.failureReported) { + this.failureReported = true + try { this.onFailure?.(error) } catch (listenerError) { + console.error('desktop host failure listener failed', listenerError) + } + } for (const pending of this.pending.values()) { void pending.requestReader?.cancel(error).catch(() => undefined) if (pending.controller === undefined) pending.reject(error) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index f98bf22902..7ac65344ea 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -2,11 +2,13 @@ import type { DesktopPluginRecord } from './project-manager.ts' import type { DesktopLocale } from './locale.ts' +import type { DesktopBackendState } from './backend-controller.ts' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { localeGet: 'dsh-desktop:locale-get', pluginsList: 'dsh-desktop:plugins-list', + pluginsOpen: 'dsh-desktop:plugins-open', pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', pluginsUpdate: 'dsh-desktop:plugins-update', @@ -14,6 +16,7 @@ export const DESKTOP_IPC = { pluginsDisableAll: 'dsh-desktop:plugins-disable-all', backendStatus: 'dsh-desktop:backend-status', backendRetry: 'dsh-desktop:backend-retry', + backendState: 'dsh-desktop:backend-state', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', updatesState: 'dsh-desktop:updates-state', @@ -39,8 +42,9 @@ export interface DshDesktopApi { disableAll(): Promise } readonly backend: { - status(): Promise<{ readonly ready: boolean; readonly error?: string }> + status(): Promise retry(): Promise + subscribe(listener: (state: DesktopBackendState) => void): () => void } readonly updates: { check(): Promise @@ -48,3 +52,8 @@ export interface DshDesktopApi { subscribe(listener: (state: DesktopUpdateState) => void): () => void } } + +/** Startup-page controls, unavailable to backend-provided application documents. */ +export interface DshDesktopStartupApi extends Pick { + openPlugins(): Promise +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 65e5083313..c937474beb 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -3,6 +3,10 @@ export const en = { application: 'Application', startupFailed: 'DeepSeek Harness could not start', + startupLoading: 'Starting DeepSeek Harness…', + startupLoadingDescription: 'Your workspace will open when it is ready.', + startupErrorDescription: 'The application could not start. Retry or manage Desktop plugins to resolve the problem.', + managePlugins: 'Manage plugins', pluginsMenu: 'Desktop Plugins…', pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', checkUpdatesMenu: 'Check for Updates…', @@ -49,6 +53,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', startupFailed: 'DeepSeek Harness 无法启动', + startupLoading: '正在启动 DeepSeek Harness…', + startupLoadingDescription: '准备就绪后将自动打开工作区。', + startupErrorDescription: '应用未能启动。你可以重试,或管理桌面插件以解决问题。', + managePlugins: '管理插件', pluginsMenu: '桌面插件…', pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', checkUpdatesMenu: '检查更新…', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a995d6818e..f132afb3b2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,6 +15,7 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' +import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts' import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' @@ -79,13 +80,13 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } -function createWindow(preload: string): BrowserWindow { +function createWindow(preload: string, show = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, height: 840, minWidth: 880, minHeight: 600, - show: false, + show, webPreferences: { preload, nodeIntegration: false, @@ -137,8 +138,9 @@ async function main(): Promise { const activeProject = development ?? paths.profile const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - let host: DesktopHostProcess | undefined - let startupError: string | undefined + let pageError: string | undefined + let quitting = false + let startup: Promise | undefined let mainWindow: BrowserWindow | undefined let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false @@ -147,6 +149,38 @@ async function main(): Promise { const messages = locale.messages const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url)) const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url)) + const startupUrl = `${SCHEME}://shell/startup.html` + const applicationUrl = `${SCHEME}://app/index.html` + let navigation: { window: BrowserWindow; url: string; promise: Promise } | undefined + + const navigateMain = (url: string): Promise => { + const window = mainWindow + if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve() + if (navigation?.window === window && navigation.url === url) return navigation.promise + const next = { window, url, promise: Promise.resolve() } + next.promise = window.loadURL(url).catch((error: unknown) => { + if (quitting || window.isDestroyed() || navigation !== next) return + navigation = undefined + throw error + }) + navigation = next + return next.promise + } + const backendState = (): DesktopBackendState => pageError === undefined + ? backend.state : { phase: 'error', message: pageError } + const publishBackend = (state: DesktopBackendState): void => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.backendState, state) + } + } + const backend = new DesktopBackendController((onFailure) => { + if (development === undefined) manager.assertProfileRuntime(activeProject) + return new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, onFailure) + }, (state) => { + if (state.phase === 'starting') pageError = undefined + publishBackend(backendState()) + if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) }) + }) const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { updateState = state @@ -156,78 +190,39 @@ async function main(): Promise { return state } - const startHost = async (projectDir = activeProject): Promise => { - if (development === undefined) manager.assertProfileRuntime(projectDir) - const next = new DesktopHostProcess(resources.node, development ?? resources.dsh, projectDir, hostInspectPort) - try { - await next.start() - return next - } catch (error) { - await next.stop() - throw error - } - } const hooks: DesktopProjectHooks = { - healthCheck: async (projectDir) => { - const active = host - host = undefined - await active?.stop() - let healthFailure: unknown - let probe: DesktopHostProcess | undefined - try { - probe = await startHost(projectDir) - await probe.stop() - } catch (error) { - healthFailure = error - await probe?.stop().catch(() => undefined) - } - let restartFailure: unknown - if (active !== undefined) { - try { - host = await startHost() - } catch (error) { - restartFailure = error - } - } - if (healthFailure !== undefined && restartFailure !== undefined) { - throw new AggregateError([ - errorOf(healthFailure, 'desktop project: staged health check failed'), - errorOf(restartFailure, 'desktop project: active backend restart failed'), - ], 'desktop project: staged health check and active backend restart failed') - } - if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed') - if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed') - }, - beforeActivate: async () => { - const active = host - host = undefined - await active?.stop() - }, - afterActivate: async () => { - host = await startHost() - }, + beforeActivate: () => backend.stop(), + afterActivate: () => backend.start(async () => {}), } - const reconcileBackend = async (): Promise => { - try { - if (development === undefined) { - await manager.applyRelease(app.getVersion(), { ...hooks, beforeActivate: async () => {}, afterActivate: async () => {} }) - } - host = await startHost() - startupError = undefined - } catch (error) { - startupError = errorOf(error, messages.startupFailed).message + const showStartupError = async (error: unknown): Promise => { + if (quitting) return + pageError = errorOf(error, messages.startupFailed).message + await navigateMain(startupUrl) + publishBackend(backendState()) + } + const reconcileBackend = (): Promise => { + startup ??= (async () => { + pageError = undefined + await navigateMain(startupUrl) + await backend.start(async () => { + if (development === undefined) { + await manager.applyRelease(app.getVersion(), { beforeActivate: async () => {}, afterActivate: async () => {} }) + } + }) + if (backend.host !== undefined) await navigateMain(applicationUrl) + })().catch(async (error: unknown) => { + await showStartupError(error) throw error - } + }).finally(() => { startup = undefined }) + return startup } const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { shellInstallerOwnsQuit = true - const active = host - host = undefined - await active?.stop() + await backend.stop() }, ) @@ -235,7 +230,7 @@ async function main(): Promise { const url = new URL(request.url) if (url.hostname === 'shell') return serveShellAsset(request) if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 })) - const active = host + const active = backend.host if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) return active.fetch(request) }) @@ -245,10 +240,16 @@ async function main(): Promise { if (development !== undefined) { throw new Error('dsh desktop: plugin package changes require a packaged application') } - await manager.mutate(mutation, hooks) - startupError = undefined - if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() - else focusPrimaryWindow() + await startup?.catch(() => undefined) + pageError = undefined + await navigateMain(startupUrl) + try { + await manager.mutate(mutation, hooks) + await navigateMain(applicationUrl) + } catch (error) { + await showStartupError(error) + throw error + } } ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { assertDesktopSender(event, ['shell']) @@ -280,11 +281,11 @@ async function main(): Promise { ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' })) ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => { assertDesktopSender(event, ['shell']) - return { ready: host !== undefined, ...(startupError === undefined ? {} : { error: startupError }) } + return backendState() }) ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => { assertDesktopSender(event, ['shell']) - if (host === undefined) await reconcileBackend() + await reconcileBackend() focusPrimaryWindow() }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { @@ -350,6 +351,10 @@ async function main(): Promise { pluginWindow.once('closed', () => { pluginWindow = undefined }) void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) } + ipcMain.handle(DESKTOP_IPC.pluginsOpen, (event) => { + assertDesktopSender(event, ['shell']) + openPluginWindow() + }) Menu.setApplicationMenu(Menu.buildFromTemplate([{ label: process.platform === 'darwin' ? app.name : messages.application, @@ -367,18 +372,17 @@ async function main(): Promise { }])) const createMainWindow = (): BrowserWindow => { - const window = createWindow(appPreload) + const window = createWindow(appPreload, true) mainWindow = window - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) return window } focusPrimaryWindow = () => { - if (host === undefined) { openPluginWindow(); return } const window = mainWindow if (window === undefined || window.isDestroyed()) { - const replacement = createMainWindow() - void replacement.loadURL(`${SCHEME}://app/index.html`) + createMainWindow() + void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl) + .catch((error: unknown) => { console.error(error) }) return } if (window.isMinimized()) window.restore() @@ -386,18 +390,6 @@ async function main(): Promise { window.focus() } - await reconcileBackend().catch(() => undefined) - if (host === undefined) openPluginWindow() - else { - mainWindow = createMainWindow() - await mainWindow.loadURL(`${SCHEME}://app/index.html`) - } - if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { - mainWindow.webContents.openDevTools({ mode: 'detach' }) - } - publishUpdate(updateState) - setTimeout(() => { void checkAndPrompt(false) }, 10_000) - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow() }) @@ -405,13 +397,23 @@ async function main(): Promise { if (process.platform !== 'darwin') app.quit() }) app.on('before-quit', (event) => { - if (shellInstallerOwnsQuit) return - if (host === undefined) return + if (shellInstallerOwnsQuit || quitting) return event.preventDefault() - const active = host - host = undefined - void active.stop().finally(() => { app.quit() }) + quitting = true + void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) }) + + mainWindow = createMainWindow() + await reconcileBackend().catch(() => undefined) + // Window lifecycle callbacks run while backend startup is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quitting) return + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + publishUpdate(updateState) + setTimeout(() => { void checkAndPrompt(false) }, 10_000) } const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() }) diff --git a/apps/desktop/src/owned-directory.ts b/apps/desktop/src/owned-directory.ts new file mode 100644 index 0000000000..0caad07ca8 --- /dev/null +++ b/apps/desktop/src/owned-directory.ts @@ -0,0 +1,26 @@ +/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */ + +import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Remove an owned directory and its contents, unlinking root and nested links. + * Missing roots are ignored; existing roots must be directories or links. + * @param path - Owned directory or link to remove; link targets are preserved. + */ +export function removeOwnedDirectory(path: string): void { + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (stat === undefined) return + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) + // Electron's recursive rm follows nested Windows junctions into installed resources. + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) removeOwnedDirectory(child) + else unlinkSync(child) + } + rmdirSync(path) +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c6a839aba6..140a7369a4 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,23 @@ -/** Minimal marker that selects the desktop custom-protocol API carrier. */ +/** Startup controls for shell documents; application documents receive only the carrier marker. */ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' +import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts' +import type { DesktopBackendState } from './backend-controller.ts' -contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) +const startup: DshDesktopStartupApi = { + protocolVersion: 1, + locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType, + backend: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, + retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.backendState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } + }, + }, + openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise, +} + +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell' + ? startup : { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4ad4243bdb..b9c04bd40a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' +import type { DesktopBackendState } from './backend-controller.ts' const api: DshDesktopApi = { protocolVersion: 1, @@ -17,6 +18,11 @@ const api: DshDesktopApi = { backend: { status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.backendState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } + }, }, updates: { check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts index 6326747b43..0ec5ca573e 100644 --- a/apps/desktop/src/profile-packages.ts +++ b/apps/desktop/src/profile-packages.ts @@ -175,7 +175,7 @@ function packageFrom(anchor: string, name: string): string | undefined { if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`) for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) { const path = join(modules, name) - if (existsSync(join(path, 'package.json'))) return realpathSync(path) + if (existsSync(join(path, 'package.json'))) return realpathSync.native(path) } return undefined } @@ -190,8 +190,8 @@ function packageFrom(anchor: string, name: string): string | undefined { export function validateDesktopPluginGraph( profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], ): void { - const profileRoot = realpathSync(profile) - const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync(runtimePath(root, entry.path))])) + const profileRoot = realpathSync.native(profile) + const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync.native(runtimePath(root, entry.path))])) for (const [name, path] of shared) { if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) } @@ -199,7 +199,7 @@ export function validateDesktopPluginGraph( const scan = (modules: string): void => { if (!existsSync(modules)) return if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`) - const directory = realpathSync(modules) + const directory = realpathSync.native(modules) if (scanned.has(directory)) return scanned.add(directory) for (const entry of readdirSync(modules, { withFileTypes: true })) { @@ -207,7 +207,7 @@ export function validateDesktopPluginGraph( const path = join(modules, entry.name) if (entry.name.startsWith('@')) { scan(path); continue } if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`) - const canonical = realpathSync(path) + const canonical = realpathSync.native(path) const info = manifest(canonical) const host = shared.get(info.name) if (host !== undefined) { diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index 89c9e86c73..f6759775e1 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -12,8 +12,8 @@ import { openSync, closeSync, readFileSync, + realpathSync, renameSync, - rmSync, unlinkSync, writeFileSync, writeSync, @@ -25,8 +25,9 @@ import { verifyDesktopCorePackageSet, } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' +import { removeOwnedDirectory } from './owned-directory.ts' import type { DesktopRelease } from './release.ts' -import { desktopRuntimeId, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' import { copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, @@ -69,10 +70,8 @@ export interface DesktopRuntimeExecutables { readonly dsh: string } -/** Hooks that bind project replacement to backend lifecycle and health. */ +/** Hooks that bind project replacement to the active backend lifecycle. */ export interface DesktopProjectHooks { - /** Prove the staged dependency graph while the active backend is stopped. */ - healthCheck(projectDir: string): Promise /** Stop the active backend and await process exit before directory moves. */ beforeActivate(): Promise /** Start the selected active project after commit or rollback. */ @@ -163,17 +162,6 @@ export function packageNameFromSpec(spec: string): string | undefined { return name } -function removeOwnedDirectory(path: string): void { - if (!existsSync(path)) return - const stat = lstatSync(path) - if (stat.isSymbolicLink()) { - unlinkSync(path) - return - } - if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) - rmSync(path, { recursive: true }) -} - function projectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) @@ -324,7 +312,7 @@ export class DesktopProjectManager { } private currentRuntime(): DesktopRuntimeDescriptor { - if (this.descriptor === undefined) throw new Error('desktop project: runtime has not been verified') + if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') return this.descriptor } @@ -334,11 +322,11 @@ export class DesktopProjectManager { validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir)) } - /** Verify this release and reconcile its external profile without installing core packages. */ + /** Read release metadata and reconcile its external profile without installing core packages. */ async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise { return this.withLock(async () => { this.recover() - const target = verifyDesktopRuntime(this.runtime.dsh, electronVersion) + const target = readDesktopRuntime(this.runtime.dsh, electronVersion) this.descriptor = target const previous = readDesktopProfileState(this.paths.profile) if (existsSync(this.paths.profile) && previous === undefined) { @@ -347,8 +335,9 @@ export class DesktopProjectManager { if (previous?.runtimeId === desktopRuntimeId(target) && previous.lockHash === desktopPluginLockHash(this.paths.profile) && previous.links.length === target.sharedPackages.length - && previous.links.every(link => link.target === join(this.runtime.dsh, 'node_modules', link.name) - && existsSync(join(this.paths.profile, 'node_modules', link.name)))) { + && previous.links.every(link => existsSync(link.target) + && existsSync(join(this.paths.profile, 'node_modules', link.name)) + && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) { validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } @@ -357,7 +346,6 @@ export class DesktopProjectManager { if (previous === undefined) createPluginProfile(stagingProfile) else copyDesktopProfile(this.paths.profile, stagingProfile) await this.reconcileProfile(stagingProfile, previous) - await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) return true } catch (error) { @@ -379,7 +367,6 @@ export class DesktopProjectManager { await this.applyMutation(stagingProfile, mutation) await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile), mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all') - await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) } catch (error) { removeOwnedDirectory(stagingProfile) diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts index 471d603964..8f02a46497 100644 --- a/apps/desktop/src/runtime-tree.ts +++ b/apps/desktop/src/runtime-tree.ts @@ -1,8 +1,9 @@ /** Relocatable, integrity-recorded production packages carried by one Desktop release. */ import { createHash } from 'node:crypto' -import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, sep } from 'node:path' +import { promisify } from 'node:util' import { valid } from 'semver' import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts' import { parseDesktopRelease, type DesktopRelease } from './release.ts' @@ -36,6 +37,7 @@ export interface DesktopRuntimeDescriptor { } const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const readRuntimeFile = promisify(readFile) function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -55,27 +57,59 @@ export function runtimePath(root: string, path: string): string { return join(root, ...path.split('/')) } -/** - * Inventory a materialized runtime without following links or including its descriptor. - * @param root - Self-contained runtime directory. - * @returns Sorted final-file inventory. - */ -export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { - const files: DesktopRuntimeFile[] = [] +function runtimeFiles(root: string): { path: string; name: string }[] { + const files: { path: string; name: string }[] = [] const visit = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name) const name = relative(root, path).split(sep).join('/') if (name === DESKTOP_RUNTIME_FILE) continue if (entry.isDirectory()) visit(path) - else if (entry.isFile()) { - const body = readFileSync(path) - files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), - executable: (lstatSync(path).mode & 0o111) !== 0 }) - } else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) + else if (entry.isFile()) files.push({ path, name }) + else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) } } visit(root) + return files +} + +function runtimeFile(path: string, name: string, body: Buffer): DesktopRuntimeFile { + return { path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), + // Windows has no portable Unix executable permission bits. + executable: process.platform !== 'win32' && (lstatSync(path).mode & 0o111) !== 0 } +} + +/** + * Inventory a materialized runtime without following links or including its descriptor. + * @param root - Self-contained runtime directory. + * @returns Sorted final-file inventory; executable permissions are false on Windows. + */ +export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { + return runtimeFiles(root).map(({ path, name }) => runtimeFile(path, name, readFileSync(path))) + .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +async function inventoryRuntimeForVerification(root: string): Promise { + const entries = runtimeFiles(root) + const files: DesktopRuntimeFile[] = [] + const remaining = entries.values() + let failed = false + const workers = Array.from({ length: Math.min(8, entries.length) }, async () => { + while (!failed) { + const next = remaining.next() + if (next.done) return + const { path, name } = next.value + try { + files.push(runtimeFile(path, name, await readRuntimeFile(path))) + } catch (error) { + failed = true + throw error + } + } + }) + // Failure returns only after every outstanding file read has closed its descriptor. + const results = await Promise.allSettled(workers) + for (const result of results) if (result.status === 'rejected') throw result.reason return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) } @@ -109,13 +143,13 @@ export function writeDesktopRuntime( } /** - * Verify the runtime before linking any of its packages into writable state. + * Read release metadata and check shared manifests and Host entries without scanning runtime contents. * @param root - Current application's runtime resources. * @param electronVersion - Expected shell version. * @param target - Required execution target; defaults to the current process. * @returns Validated runtime descriptor. */ -export function verifyDesktopRuntime( +export function readDesktopRuntime( root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, ): DesktopRuntimeDescriptor { const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) @@ -144,13 +178,6 @@ export function verifyDesktopRuntime( runtimePath(root, entry.path) return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable } }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) - const actual = inventoryDesktopRuntime(root) - // Windows has no portable Unix executable permission bits. - const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' - ? items.map(({ executable: _executable, ...item }) => item) : items - if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) { - throw new Error('desktop runtime: integrity verification failed') - } for (const entry of sharedPackages) { const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { @@ -163,13 +190,36 @@ export function verifyDesktopRuntime( } } for (const file of DESKTOP_HOST_RUNTIME_FILES) { - if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) { + const path = `node_modules/${DESKTOP_HOST_PACKAGE}/${file}` + if (!files.some(entry => entry.path === path) || !existsSync(runtimePath(root, path)) + || !lstatSync(runtimePath(root, path)).isFile()) { throw new Error(`desktop runtime: missing Host file ${file}`) } } return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files } } +/** + * Verify every packaged runtime file against its recorded bytes and permissions at build time. + * @param root - Materialized runtime resources. + * @param electronVersion - Expected shell version. + * @param target - Required execution target; defaults to the current process. + * @returns Validated runtime descriptor. + */ +export async function verifyDesktopRuntime( + root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, +): Promise { + const descriptor = readDesktopRuntime(root, electronVersion, target) + const actual = await inventoryRuntimeForVerification(root) + // Windows has no portable Unix executable permission bits. + const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' + ? items.map(({ executable: _executable, ...item }) => item) : items + if (JSON.stringify(comparable(descriptor.files)) !== JSON.stringify(comparable(actual))) { + throw new Error('desktop runtime: integrity verification failed') + } + return descriptor +} + /** * Identify exact runtime content independently of its installation path. * @param descriptor - Validated runtime metadata. diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts new file mode 100644 index 0000000000..abdc78a9e7 --- /dev/null +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts' + +function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((accept, decline) => { resolve = accept; reject = decline }) + return { promise, resolve, reject } +} + +function fixture() { + const started = deferred() + const ready = deferred() + const stopping = deferred() + const exited = deferred() + const states: DesktopBackendState[] = [] + let fail!: (error: Error) => void + const host = { + start: vi.fn(() => { started.resolve(); return ready.promise }), + stop: vi.fn(() => { stopping.resolve(); return exited.promise }), + } + const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host }) + const controller = new DesktopBackendController(create, state => states.push(state)) + return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } } +} + +describe('desktop backend controller', () => { + it('shares preparation and startup between concurrent retries', async () => { + const f = fixture() + const prepare = vi.fn(async () => {}) + const first = f.controller.start(prepare) + expect(f.controller.start(prepare)).toBe(first) + await f.started.promise + expect(f.controller.host).toBeUndefined() + expect(f.states).toEqual([{ phase: 'starting' }]) + f.ready.resolve() + await first + expect(f.controller.host).toBe(f.host) + await f.controller.start(prepare) + expect(prepare).toHaveBeenCalledTimes(1) + expect(f.host.start).toHaveBeenCalledTimes(1) + f.exited.resolve() + await f.controller.close() + }) + + it('waits for pending preparation on close and never spawns afterward', async () => { + const f = fixture() + const preparing = deferred() + const prepared = deferred() + const start = f.controller.start(() => { preparing.resolve(); return prepared.promise }) + await preparing.promise + let closed = false + const close = f.controller.close().then(() => { closed = true }) + await Promise.resolve() + expect(closed).toBe(false) + prepared.resolve() + await Promise.all([start, close]) + expect(f.create).not.toHaveBeenCalled() + expect(f.states).toEqual([{ phase: 'starting' }]) + await expect(f.controller.start(async () => {})).rejects.toThrow('closed') + }) + + it('stops a pending child once and waits for startup and child exit on close', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('stopped') + await f.started.promise + const close = f.controller.close() + expect(f.controller.close()).toBe(close) + await f.stopping.promise + let closed = false + void close.then(() => { closed = true }) + f.ready.reject(new Error('stopped')) + await Promise.resolve() + expect(closed).toBe(false) + f.exited.resolve() + await Promise.all([rejected, close]) + expect(f.host.stop).toHaveBeenCalledTimes(1) + expect(f.states).toEqual([{ phase: 'starting' }]) + expect(f.controller.host).toBeUndefined() + }) + + it('publishes preparation errors and permits retry', async () => { + const f = fixture() + await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) + expect(f.create).not.toHaveBeenCalled() + f.ready.resolve() + await f.controller.start(async () => {}) + expect(f.controller.state).toEqual({ phase: 'ready' }) + f.exited.resolve() + await f.controller.close() + }) + + it('cleans up a failed startup before rejecting and displays the actual failure', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('plugin failed') + await f.started.promise + f.ready.reject(new Error('plugin failed')) + await f.stopping.promise + f.exited.resolve() + await rejected + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) + expect(f.host.stop).toHaveBeenCalledTimes(1) + await f.controller.close() + }) + + it('waits for failed ready child cleanup before preparing a retry', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + f.fail(new Error('transport failed')) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) + expect(f.controller.host).toBeUndefined() + await f.stopping.promise + const prepare = vi.fn(async () => {}) + const retry = f.controller.start(prepare) + await Promise.resolve() + expect(prepare).not.toHaveBeenCalled() + f.exited.resolve() + await retry + expect(prepare).toHaveBeenCalledTimes(1) + expect(f.create).toHaveBeenCalledTimes(2) + await f.controller.close() + }) + + it('does not publish ready when the child fails as readiness settles', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('immediate failure') + await f.started.promise + f.ready.resolve() + f.fail(new Error('immediate failure')) + await f.stopping.promise + f.exited.resolve() + await rejected + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) + await f.controller.close() + }) + + it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + const stop = f.controller.stop() + await f.stopping.promise + f.fail(new Error('stopped')) + await expect(f.controller.start(async () => {})).rejects.toThrow('stopping') + expect(f.controller.state).toEqual({ phase: 'starting' }) + f.exited.resolve() + await stop + await f.controller.start(async () => {}) + expect(f.create).toHaveBeenCalledTimes(2) + await f.controller.close() + }) + + it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + f.fail(new Error('fatal')) + await f.stopping.promise + f.exited.reject(new Error('child did not exit')) + await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit') + await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit') + expect(f.create).toHaveBeenCalledTimes(1) + await expect(f.controller.close()).rejects.toThrow('child did not exit') + }) +}) diff --git a/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi b/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi new file mode 100644 index 0000000000..2c1c47ae34 --- /dev/null +++ b/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi @@ -0,0 +1,70 @@ +; Compile with OUTPUT_FILE and RESULT_FILE in a private test directory, then run silently. +Unicode true +RequestExecutionLevel user +SilentInstall silent +Name "Desktop installer cleanup smoke" +OutFile "${OUTPUT_FILE}" +!include "..\..\scripts\installer.nsh" + +Section + InitPluginsDir + SetOutPath "$PLUGINSDIR" + CreateDirectory "$PLUGINSDIR\7z-out\nested" + FileOpen $1 "$PLUGINSDIR\7z-out\nested\extracted-file" w + FileWrite $1 "remove" + FileClose $1 + FileOpen $1 "$PLUGINSDIR\archive.7z" w + FileWrite $1 "archive sentinel" + FileClose $1 + FileOpen $1 "$PLUGINSDIR\plugin.dll" w + FileWrite $1 "plugin sentinel" + FileClose $1 + CreateDirectory "$PLUGINSDIR\old-install" + FileOpen $1 "$PLUGINSDIR\old-install\sentinel" w + FileWrite $1 "rollback sentinel" + FileClose $1 + + StrCpy $0 "register sentinel" + ClearErrors + !insertmacro customInstall + IfErrors failed + StrCmp $0 "register sentinel" 0 failed + StrCmp $OUTDIR $PLUGINSDIR 0 failed + IfFileExists "$PLUGINSDIR\7z-out\*.*" failed + + FileOpen $1 "$PLUGINSDIR\archive.7z" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "archive sentinel" 0 failed + FileOpen $1 "$PLUGINSDIR\plugin.dll" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "plugin sentinel" 0 failed + FileOpen $1 "$PLUGINSDIR\old-install\sentinel" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "rollback sentinel" 0 failed + + CreateDirectory "$PLUGINSDIR\7z-out" + SetErrors + !insertmacro customInstall + IfErrors +2 + Goto failed + IfFileExists "$PLUGINSDIR\7z-out\*.*" failed + + ClearErrors + !insertmacro customInstall + IfErrors failed + FileOpen $1 "${RESULT_FILE}" w + FileWrite $1 "scratch removed; archive, plugin, rollback, registers and error flags preserved" + FileClose $1 + SetErrorLevel 0 + Quit + + failed: + FileOpen $1 "${RESULT_FILE}" w + FileWrite $1 "cleanup smoke failed" + FileClose $1 + SetErrorLevel 1 + Quit +SectionEnd diff --git a/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi b/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi new file mode 100644 index 0000000000..d7976e51ad --- /dev/null +++ b/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi @@ -0,0 +1,55 @@ +; Compile with OUTPUT_FILE, RESULT_FILE, PAYLOAD_FILE, TARGET_DIR and PLUGIN_DIR. +; All paths must belong to a private test directory, except the NSIS plugin directory. +; PAYLOAD_FILE contains locked.txt="new runtime" and asset.txt="new asset". +; Before running /S, create TARGET_DIR\locked.txt="old runtime" and hold it open +; for reading with FileShare.Read until the native process exits. asset.txt is absent. +; Define DIRECT for direct extraction; otherwise extract to temporary files and copy. +; Both modes retain "old runtime" and write "new asset" under that lock. The copy +; sets errorFlag=true; direct extraction reports false despite the mixed versions. +; Exit code 0 alone cannot establish successful extraction; inspect RESULT_FILE. +Unicode true +RequestExecutionLevel user +SilentInstall silent +Name "Desktop occupied-file extraction smoke" +OutFile "${OUTPUT_FILE}" +!addplugindir /x86-unicode "${PLUGIN_DIR}" + +Section + InitPluginsDir + SetOutPath "${TARGET_DIR}" + !ifdef DIRECT + ClearErrors + Nsis7z::Extract "${PAYLOAD_FILE}" + !else + SetOutPath "$PLUGINSDIR\7z-out" + Nsis7z::Extract "${PAYLOAD_FILE}" + SetOutPath "${TARGET_DIR}" + ClearErrors + CopyFiles /SILENT "$PLUGINSDIR\7z-out\*" $OUTDIR + !endif + + StrCpy $0 "false" + IfErrors 0 +2 + StrCpy $0 "true" + ClearErrors + FileOpen $1 "${RESULT_FILE}" w + IfErrors failed + FileWrite $1 "errorFlag=$0$\r$\n" + FileOpen $2 "${TARGET_DIR}\locked.txt" r + IfErrors failed + FileRead $2 $3 + FileClose $2 + FileWrite $1 "locked=$3$\r$\n" + FileOpen $2 "${TARGET_DIR}\asset.txt" r + IfErrors failed + FileRead $2 $3 + FileClose $2 + FileWrite $1 "asset=$3$\r$\n" + FileClose $1 + SetErrorLevel 0 + Quit + + failed: + SetErrorLevel 1 + Quit +SectionEnd diff --git a/apps/desktop/tests/fixtures/owned-directory-smoke.mjs b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs new file mode 100644 index 0000000000..52315992ba --- /dev/null +++ b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs @@ -0,0 +1,29 @@ +/** Built Desktop cleanup smoke; run with Electron and ELECTRON_RUN_AS_NODE=1. */ + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeOwnedDirectory } from '../../lib/types/owned-directory.js' +import { inventoryDesktopRuntime } from '../../lib/types/runtime-tree.js' + +assert.ok(process.versions.electron, 'This regression must run under Electron') +const root = mkdtempSync(join(tmpdir(), 'desktop-electron-cleanup-')) +try { + const resources = join(root, 'resources') + mkdirSync(resources) + writeFileSync(join(resources, 'sentinel'), 'installed runtime bytes') + const inventory = inventoryDesktopRuntime(resources) + const profile = join(root, 'rollback-profile') + const packages = join(profile, 'node_modules', '@unknown') + mkdirSync(packages, { recursive: true }) + symlinkSync(resources, join(packages, 'host-package'), process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(join(packages, 'private-file'), 'removed') + removeOwnedDirectory(profile) + assert.equal(existsSync(profile), false) + assert.deepEqual(inventoryDesktopRuntime(resources), inventory) + assert.equal(readFileSync(join(resources, 'sentinel'), 'utf8'), 'installed runtime bytes') + console.log(JSON.stringify({ electron: process.versions.electron, node: process.version, preservedLinkTarget: true })) +} finally { + removeOwnedDirectory(root) +} diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs new file mode 100644 index 0000000000..da2d5c478d --- /dev/null +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -0,0 +1,138 @@ +/** Exercise filtered Desktop native and HTML dependencies under its bundled Node. */ + +import assert from 'node:assert/strict' +import { closeSync, mkdtempSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const runtime = process.argv[2] +assert.ok(runtime, 'Pass the filtered resources/dsh directory') +const root = resolve(runtime) +const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), 'utf8')) +assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the bundled Node version') +assert.equal(process.platform, descriptor.platform) +assert.equal(process.arch, descriptor.arch) +const requireRuntime = createRequire(join(root, 'package.json')) +const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) + +/** Spawn only a fixed Node program and await the terminal's drained exit event. */ +async function checkPty() { + const pty = requireRuntime('node-pty') + const script = join(scratch, 'pty.cjs') + writeFileSync(script, "process.stdout.write('runtime-payload-pty-ok\\n')\n", { flag: 'wx', mode: 0o600 }) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + /^(?:path|systemroot|windir|comspec)$/iu.test(name) + ))) + Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }) + const terminal = pty.spawn(process.execPath, [script], { cwd: scratch, env, cols: 80, rows: 24 }) + let output = '' + let exited = false + let timedOut = false + let exitSubscription + const exit = new Promise(resolveExit => { + exitSubscription = terminal.onExit(event => { + exited = true + resolveExit(event) + }) + }) + const dataSubscription = terminal.onData(data => { output += data }) + let timer + try { + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true + reject(new Error('Packaged PTY did not exit within 45 seconds')) + }, 45_000) + }) + const result = await Promise.race([exit, deadline]) + assert.equal(timedOut, false) + assert.ok(result.signal === undefined || result.signal === 0, 'PTY exited without a signal') + assert.equal(result.exitCode, 0) + assert.match(output, /runtime-payload-pty-ok/u) + } finally { + clearTimeout(timer) + dataSubscription.dispose() + try { + // node-pty's Windows natural-exit event closes output but leaves its ConPTY worker owned by kill(). + if (!exited || process.platform === 'win32') terminal.kill() + await exit + } finally { + exitSubscription.dispose() + } + } +} + +/** fs-ext implements seek on Windows through SetFilePointerEx and on POSIX through lseek. */ +function checkFsExt() { + const fsExt = requireRuntime('fs-ext') + const file = join(scratch, 'seek.txt') + writeFileSync(file, 'abcdef', { flag: 'wx', mode: 0o600 }) + const fd = openSync(file, 'r') + try { + assert.equal(fsExt.seekSync(fd, 2, fsExt.constants.SEEK_SET), 2) + const bytes = Buffer.alloc(4) + assert.equal(readSync(fd, bytes, 0, bytes.length, null), 4) + assert.equal(bytes.toString(), 'cdef') + } finally { + closeSync(fd) + } +} + +/** Resolve one system function through Koffi's packaged native module. */ +function checkKoffi() { + const koffi = requireRuntime('koffi') + const library = koffi.load(process.platform === 'win32' ? 'kernel32.dll' : null) + try { + const getPid = process.platform === 'win32' + ? library.func('uint32_t __stdcall GetCurrentProcessId(void)') + : library.func('int getpid(void)') + assert.equal(getPid(), process.pid) + } finally { + library.unload() + } +} + +/** Encode and decode a pixel through the packaged libvips binary. */ +async function checkSharp() { + const sharp = requireRuntime('sharp') + const pixel = Buffer.from([17, 103, 231]) + const png = await sharp(pixel, { raw: { width: 1, height: 1, channels: 3 } }).png().toBuffer() + const decoded = await sharp(png).raw().toBuffer({ resolveWithObject: true }) + assert.equal(decoded.info.width, 1) + assert.equal(decoded.info.height, 1) + assert.equal(decoded.info.channels, 3) + assert.deepEqual(decoded.data, pixel) +} + +/** Exercise Domino parsing through the HTML converter and GFM plugin used by web_fetch. */ +function checkHtml() { + const Turndown = requireRuntime('turndown') + const { gfm } = requireRuntime('@joplin/turndown-plugin-gfm') + const converter = new Turndown({ bulletListMarker: '-' }) + converter.use(gfm) + const markdown = converter.turndown('

A & B ©

  • first
  • second
' + + '
NameValue
x7
') + assert.match(markdown, /A & B ©/u) + assert.match(markdown, /-\s+first\n-\s+second/u) + assert.match(markdown, /\| Name \| Value \|/u) + assert.match(markdown, /\| x\s+\| 7\s+\|/u) +} + +try { + checkFsExt() + checkKoffi() + await checkSharp() + checkHtml() + await checkPty() +} finally { + // This private tree contains only fixture files; Windows may release handles after terminal exit. + await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) +} + +// Natural event-loop drain includes node-pty's worker and console-list helper teardown. +process.once('beforeExit', () => { + console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch, + fsExt: true, koffi: true, sharp: true, html: true, pty: true })) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index 9a9a2d4730..f41650c26e 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { DesktopHostProcess } from '../src/host-process.ts' const roots: string[] = [] @@ -75,6 +75,30 @@ afterEach(() => { }) describe('desktop host process', () => { + it('reports a fatal event after readiness once and stops the child', async () => { + const runtime = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: '1.0.0' }) +function onRequestFrame(frame) { + if (frame.type === 1) process.send({ type: 'fatal', message: 'plugin unavailable' }) +} +`) + const failure = vi.fn() + const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, failure) + try { + await host.start() + await expect(host.fetch(new Request('dsh-app://app/'))).rejects.toThrow('plugin unavailable') + await host.stop() + expect(failure).toHaveBeenCalledTimes(1) + expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable')) + } finally { await host.stop() } + }) + + it('settles teardown when the executable cannot be spawned', async () => { + const runtime = projectWithHost('function onRequestFrame() {}') + const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime) + try { await expect(host.start()).rejects.toThrow() } finally { await host.stop() } + }) + it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => { const runtime = projectWithHost(` process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' }) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index ada386cc38..e481d9a9f0 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -105,10 +105,10 @@ describe('desktop macOS release signature', () => { join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value, )) await copyFiles(matchers.slice(0, 1)) - expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).toThrow(/integrity/u) + await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).rejects.toThrow(/ENOENT/u) rmSync(destination, { recursive: true }) await copyFiles(matchers) - expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).not.toThrow() + await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).resolves.toMatchObject({ release: { version: '1.0.0' } }) } finally { rmSync(root, { recursive: true, force: true }) } }) @@ -120,6 +120,29 @@ describe('desktop macOS release signature', () => { }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) }) + it('isolates unsigned Windows artifacts and omits updater metadata without release credentials', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_TARGET_PLATFORM: 'win32', + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64') + expect(portablePath(config.directories.output)).toContain('/targets/win-x64/unsigned-artifacts') + expect(portablePath(config.nsis.include)).toMatch(/\/scripts\/installer\.nsh$/u) + expect(config).toMatchObject({ + win: { forceCodeSigning: false, signtoolOptions: { sign: undefined } }, + publish: null, + }) + }) + + it('rejects unsigned macOS builds and malformed signing modes', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: '1' })) + .toThrow(/unsigned builds require Windows/u) + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: 'yes' })) + .toThrow(/must be 0 or 1/u) + }) + it('accepts the configured authority and team', () => { const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) expect(() => { diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts new file mode 100644 index 0000000000..252db0a18c --- /dev/null +++ b/apps/desktop/tests/main-startup.spec.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const harness = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((accept, decline) => { resolve = accept; reject = decline }) + return { promise, resolve, reject } + } + const windows: FakeWindow[] = [] + const hosts: FakeHost[] = [] + const handlers = new Map unknown>() + let preparing = deferred() + let prepared = deferred() + let hostStarted = deferred() + let navigated = deferred() + let errorPublished = deferred() + let quitCompleted = deferred() + class FakeWindow extends EventEmitter { + destroyed = false + readonly urls: string[] = [] + readonly webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler: vi.fn(), + openDevTools: vi.fn(), + send: vi.fn((channel: string, state: { phase?: string }) => { + if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve() + }), + }) + readonly show = vi.fn() + readonly focus = vi.fn() + readonly restore = vi.fn() + constructor(readonly options: { show: boolean }) { super(); windows.push(this) } + isDestroyed() { return this.destroyed } + isMinimized() { return false } + async loadURL(url: string) { + this.urls.push(url) + if (url === 'dsh-app://app/index.html') navigated.resolve() + } + static getAllWindows() { return windows.filter(window => !window.destroyed) } + close() { this.destroyed = true; this.emit('closed') } + } + class FakeHost { + readonly ready = deferred() + readonly exited = deferred() + readonly stopping = deferred() + readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise }) + readonly stop = vi.fn(() => { + this.stopping.resolve() + this.ready.reject(new Error('child stopped')) + return this.exited.promise + }) + constructor(..._args: unknown[]) { hosts.push(this) } + } + const app = Object.assign(new EventEmitter(), { + isPackaged: false, + name: 'Desktop test', + whenReady: () => Promise.resolve(), + getLocale: () => 'en-US', + getVersion: () => '1.0.0', + requestSingleInstanceLock: () => true, + exit: vi.fn(), + quit: vi.fn(() => { + const event = { preventDefault: vi.fn() } + app.emit('before-quit', event) + if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve() + }), + }) + return { + windows, hosts, handlers, app, FakeWindow, FakeHost, + dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, + applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), + get preparing() { return preparing }, get prepared() { return prepared }, + get hostStarted() { return hostStarted }, get navigated() { return navigated }, + get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted }, + nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, + reset() { + windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + preparing = deferred(); prepared = deferred(); hostStarted = deferred() + navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred() + }, + } +}) + +vi.mock('electron', () => ({ + app: harness.app, + BrowserWindow: harness.FakeWindow, + dialog: harness.dialog, + ipcMain: { + handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) }, + }, + Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, +})) +vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) })) +vi.mock('../src/project-manager.ts', () => ({ + DesktopProjectManager: class { + readonly applyRelease = harness.applyRelease + readonly assertProfileRuntime = vi.fn() + }, +})) +vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost })) +vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() })) + +function invoke(channel: string): unknown { + const handler = harness.handlers.get(channel) + if (handler === undefined) throw new Error(`missing handler ${channel}`) + return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } }) +} + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.useFakeTimers() + harness.reset() + vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node') + vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') + vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') + vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined) +}) + +afterEach(async () => { + harness.prepared.resolve() + for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() } + harness.app.quit() + await harness.quitCompleted.promise + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +describe('desktop main startup', () => { + it('shows the loading window before profile preparation and starts one actual Host', async () => { + await import('../src/main.ts') + await harness.preparing.promise + expect(harness.windows).toHaveLength(1) + const window = harness.windows[0]! + expect(window.options.show).toBe(true) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + expect(harness.hosts).toHaveLength(0) + const retry = invoke(DESKTOP_IPC.backendRetry) + const secondRetry = invoke(DESKTOP_IPC.backendRetry) + harness.prepared.resolve() + await harness.hostStarted.promise + expect(harness.hosts).toHaveLength(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + harness.hosts[0]!.ready.resolve() + await Promise.all([retry, secondRetry, harness.navigated.promise]) + expect(harness.applyRelease).toHaveBeenCalledTimes(1) + expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) + expect(harness.windows).toHaveLength(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html']) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('keeps startup errors and a successful retry in the same window', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const first = harness.hosts[0]! + const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed') + first.exited.resolve() + first.ready.reject(new Error('plugin composition failed')) + await harness.errorPublished.promise + await failedRetry + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' }) + expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html']) + const nextStarted = harness.nextHostStart() + const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + await nextStarted + expect(harness.hosts).toHaveLength(2) + harness.hosts[1]!.ready.resolve() + await retry + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/index.html') + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('waits for a pending child to exit on quit without late window navigation', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const window = harness.windows[0]! + const host = harness.hosts[0]! + host.stop.mockImplementation(() => { host.stopping.resolve(); return host.exited.promise }) + window.close() + harness.app.quit() + await host.stopping.promise + expect(harness.app.quit).toHaveBeenCalledTimes(1) + host.ready.resolve() + host.exited.resolve() + await harness.quitCompleted.promise + expect(host.stop).toHaveBeenCalledTimes(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + expect(harness.windows).toHaveLength(1) + }) +}) diff --git a/apps/desktop/tests/owned-directory.spec.ts b/apps/desktop/tests/owned-directory.spec.ts new file mode 100644 index 0000000000..081e5adc76 --- /dev/null +++ b/apps/desktop/tests/owned-directory.spec.ts @@ -0,0 +1,53 @@ +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeOwnedDirectory } from '../src/owned-directory.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-owned-directory-')) + roots.push(root) + return root +} + +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('removes nested files and directories while preserving unknown directory-link targets', () => { + const root = fixture() + const target = join(root, 'target') + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + const owned = join(root, 'owned') + const nested = join(owned, 'node_modules', '@unknown', 'private') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'file'), 'remove') + symlinkSync(target, join(nested, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(join(root, 'missing-target'), join(owned, 'broken'), process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(owned) + expect(existsSync(owned)).toBe(false) + expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it.each([false, true])('unlinks a root directory link with missing target: %s', (missing) => { + const root = fixture() + const target = join(root, 'target') + if (!missing) { + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + } + const link = join(root, 'link') + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(link) + expect(lstatSync(link, { throwIfNoEntry: false })).toBeUndefined() + if (!missing) expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it('ignores a missing root and refuses to delete a regular-file root', () => { + const root = fixture() + expect(() => { removeOwnedDirectory(join(root, 'missing')) }).not.toThrow() + const file = join(root, 'file') + writeFileSync(file, 'preserved') + expect(() => { removeOwnedDirectory(file) }).toThrow(/not a directory/u) + expect(readFileSync(file, 'utf8')).toBe('preserved') +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index ae5e0ca800..d284747e86 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { desktopElectronBuilderArguments, + desktopElectronBuilderEnvironment, parseDesktopPackageInvocation, resolveDesktopPackageTarget, withoutDesktopUploadCredentials, @@ -56,6 +57,45 @@ describe('desktop package target', () => { expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') }) + it('accepts unsigned Windows artifacts and rejects other targets or preparation-only use', () => { + expect(parseDesktopPackageInvocation(['win-x64', '--unsigned'], 'win32', 'x64').unsigned).toBe(true) + expect(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64').unsigned).toBe(false) + expect(parseDesktopPackageInvocation(['--unsigned', '--dir'], 'win32', 'x64')).toMatchObject({ + unsigned: true, directory: true, + }) + expect(() => parseDesktopPackageInvocation(['mac-arm64', '--unsigned'], 'darwin', 'arm64')) + .toThrow(/requires win-x64/u) + expect(() => parseDesktopPackageInvocation(['--unsigned', '--prepare-only'], 'win32', 'x64')) + .toThrow(/cannot use --prepare-only/u) + }) + + it('removes ambient certificate inputs for unsigned builds and overrides an inherited signing mode', () => { + const environment = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + CSC_LINK: 'private.pfx', + CSC_KEY_PASSWORD: 'secret', + WIN_CSC_LINK: 'windows.pfx', + CSC_IDENTITY_AUTO_DISCOVERY: 'true', + DSH_DESKTOP_UNSIGNED: '1', + } + expect(desktopElectronBuilderEnvironment(environment, true)).toEqual({ + DSH_DESKTOP_APP_ID: 'com.example.desktop', + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + DSH_DESKTOP_UNSIGNED: '1', + }) + expect(desktopElectronBuilderEnvironment(environment, false)).toEqual({ ...environment, DSH_DESKTOP_UNSIGNED: '0' }) + }) + + it.each([false, true])('pins the Windows archive filter for the NSIS decoder (unsigned: %s)', (unsigned) => { + expect(desktopElectronBuilderEnvironment({ + DSH_DESKTOP_TARGET_PLATFORM: 'win32', ELECTRON_BUILDER_7Z_FILTER: 'ARM64', + }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('BCJ') + expect(desktopElectronBuilderEnvironment({ + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', ELECTRON_BUILDER_7Z_FILTER: 'ARM', + }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('ARM') + }) + it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => { expect(withoutWindowsSigningEnvironment({ DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts index 376b523f58..d870b7ecc7 100644 --- a/apps/desktop/tests/plugin-manager.spec.ts +++ b/apps/desktop/tests/plugin-manager.spec.ts @@ -12,7 +12,7 @@ it('keeps disabled packages visible and offers recovery without a running backen const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) const api = { locale: async () => resolveDesktopLocale('en'), - backend: { status: async () => ({ ready, error: ready ? undefined : 'plugin requires Cordis ^2.0.0' }), retry: vi.fn() }, + backend: { status: async () => ready ? { phase: 'ready' } : { phase: 'error', message: 'plugin requires Cordis ^2.0.0' }, retry: vi.fn() }, plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle }, } Object.defineProperty(dom.window, 'dshDesktop', { value: api }) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts index 1a455e3453..f565967cee 100644 --- a/apps/desktop/tests/plugin-pnpm.spec.ts +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -47,7 +47,7 @@ it('installs a real pnpm graph, then executes approved scripts with the shared h const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) - const hooks: DesktopProjectHooks = { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {} } + const hooks: DesktopProjectHooks = { beforeActivate: async () => {}, afterActivate: async () => {} } await manager.applyRelease('1.0.0', hooks) await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts new file mode 100644 index 0000000000..81eac02cc3 --- /dev/null +++ b/apps/desktop/tests/preload-app.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { DESKTOP_IPC, type DshDesktopStartupApi } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn() }, +})) +vi.mock('electron', () => electron) + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) + +it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes only the carrier marker to %s', async (url) => { + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + +it('provides startup controls and a removable state subscription to shell documents', async () => { + vi.stubGlobal('location', new URL('dsh-app://shell/startup.html')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi + await api.locale() + await api.backend.status() + await api.backend.retry() + await api.openPlugins() + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen], + ]) + const listener = vi.fn() + const dispose = api.backend.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void + handler({}, { phase: 'error', message: 'startup failed' }) + expect(listener).toHaveBeenCalledWith({ phase: 'error', message: 'startup failed' }) + dispose() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.backendState, handler) + expect(api).not.toHaveProperty('plugins') +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 18e5c26c7a..9d141efa2d 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -48,7 +48,7 @@ if (command !== 'rebuild') { return path } function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } + return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } } function setup(): { root: string; manager: DesktopProjectManager } { const root = temporaryRoot() @@ -91,6 +91,36 @@ describe('desktop external plugin profile', () => { expect(calls(root)).toEqual([]) }) + it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => { + const { manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() }) + let started = false + await expect(relaunched.applyRelease('1.0.0', hooks({ + afterActivate: async () => { started = true }, + }))).resolves.toBe(false) + expect(started).toBe(false) + }) + + it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { + const { root, manager } = setup() + if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}') + if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') + if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') + if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json')) + let starts = 0 + await expect(manager.applyRelease('1.0.0', hooks({ + afterActivate: async () => { starts++ }, + }))).resolves.toBe(true) + const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) + await expect(relaunched.applyRelease('1.0.0', hooks({ + afterActivate: async () => { starts++ }, + }))).resolves.toBe(false) + expect(starts).toBe(1) + expect(existsSync(manager.paths.profile)).toBe(true) + expect(calls(root)).toEqual([]) + }) + it('installs only plugins and checks the graph before running lifecycle scripts', async () => { const { root, manager } = setup() await manager.applyRelease('1.0.0', hooks()) @@ -166,12 +196,11 @@ describe('desktop external plugin profile', () => { expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) }) - it.each(['health', 'before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { + it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { const { manager } = setup() await manager.applyRelease('1.0.0', hooks()) let starts = 0 await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - healthCheck: async () => { if (phase === 'health') throw new Error('health failed') }, beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') }, afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') }, }))).rejects.toThrow(`${phase} failed`) diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts new file mode 100644 index 0000000000..837fd54bf1 --- /dev/null +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -0,0 +1,81 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { expect, it } from 'vitest' +import { desktopRuntimeFileExclusion } from '../scripts/runtime-file-policy.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const windows = { platform: 'win32' as const, arch: 'x64' } + +it('omits development artifacts while preserving executable modules, assets and license files', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-file-policy-')) + const source = join(root, 'source') + const output = join(root, 'output') + const removed = [ + 'example/index.d.ts', 'example/index.d.mts', 'example/index.d.cts', + 'example/index.js.map', 'example/index.mjs.map', 'example/index.cjs.map', + 'example/style.css.map', 'example/index.d.ts.map', 'example/index.d.mts.map', + 'example/index.d.cts.map', 'example/tsconfig.tsbuildinfo', + 'fs-ext/build/Release/obj/fs_ext/native.obj', 'fs-ext/build/Release/fs_ext.pdb', + 'fs-ext/build/Release/fs_ext.lib', 'fs-ext/build/Release/fs_ext.exp', + 'fs-ext/build/Release/fs_ext.iobj', 'fs-ext/build/Release/fs_ext.ipdb', + 'fs-ext/build/binding.sln', 'fs-ext/build/config.gypi', + 'fs-ext/build/fs_ext.vcxproj', 'fs-ext/build/fs_ext.vcxproj.filters', + 'node-pty/prebuilds/win32-arm64/conpty.node', + 'node-pty/prebuilds/linux-x64/pty.node', 'node-pty/prebuilds/darwin-x64/pty.node', + 'node-pty/prebuilds/win32-x64/conpty.pdb', + '@koromix/koffi-win32-x64/win32_x64/koffi.lib', + '@mixmark-io/domino/test/entities.html', + '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', + ] + const retained = [ + 'example/index.js', 'example/index.cjs', 'example/index.mjs', 'example/worker.js', + 'example/source.ts', 'example/src/entry.ts', 'example/test/runtime-fixture.json', + 'example/locale.json', 'example/data.map', 'example/module.wasm', + 'example/package.json', 'example/LICENSE', 'example/NOTICE', 'example/README.md', + 'example/native.lib', 'example/native.obj', 'example/symbols.pdb', + 'fs-ext/build/Release/fs_ext.node', 'fs-ext/build/Release/runtime.dll', + 'node-pty/prebuilds/win32-x64/conpty.node', + 'node-pty/prebuilds/win32-x64/conpty_console_list.node', + 'node-pty/prebuilds/win32-x64/conpty/conpty.dll', + 'node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe', + 'node-pty/third_party/conpty/win10-x64/OpenConsole.exe', + '@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', + ] + try { + const runtime = runtimeFixture(source) + const modules = join(source, 'node_modules') + for (const path of [...removed, ...retained]) { + mkdirSync(join(modules, path, '..'), { recursive: true }) + writeFileSync(join(modules, path), `payload:${path}`) + } + cpSync(modules, join(output, 'node_modules'), { + recursive: true, dereference: true, + filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === 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}`) + const sealed = writeDesktopRuntime(output, runtime.release, runtime.sharedPackages.map(entry => entry.name)) + expect(await verifyDesktopRuntime(output, runtime.release.version)).toEqual(sealed) + expect(readFileSync(join(modules, removed[0]!), 'utf8')).toBe(`payload:${removed[0]}`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +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() +}) + +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() +}) diff --git a/apps/desktop/tests/runtime-tree-read-failure.spec.ts b/apps/desktop/tests/runtime-tree-read-failure.spec.ts new file mode 100644 index 0000000000..042ff77c6d --- /dev/null +++ b/apps/desktop/tests/runtime-tree-read-failure.spec.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, readFile, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +vi.mock('node:fs', async importOriginal => ({ + ...await importOriginal(), + readFile: vi.fn(), +})) + +it('drains outstanding reads before rejecting and stops scheduling after a read failure', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-read-failure-')) + const pending: ((error?: Error) => void)[] = [] + vi.mocked(readFile).mockImplementation((...args: unknown[]) => { + const path = args[0] + const callback = args.at(-1) + if (typeof path !== 'string' || typeof callback !== 'function') throw new Error('unexpected readFile arguments') + const complete = callback as (error: Error | null, body: Buffer | undefined) => void + pending.push((error) => { complete(error ?? null, error === undefined ? readFileSync(path) : undefined) }) + }) + let outcome: Promise | undefined + try { + runtimeFixture(root) + let settled = false + outcome = verifyDesktopRuntime(root, '1.0.0').finally(() => { settled = true }).catch((error: unknown) => error) + const inFlight = pending.length + expect(inFlight).toBeGreaterThan(1) + const failure = new Error('runtime read failed') + pending.shift()!(failure) + try { + // Pending callbacks keep the reads open across a complete event-loop turn. + await setImmediate() + expect(settled).toBe(false) + expect(pending).toHaveLength(inFlight - 1) + } finally { + for (const complete of pending.splice(0)) complete() + } + expect(await outcome).toBe(failure) + expect(pending).toHaveLength(0) + } finally { + for (const complete of pending.splice(0)) complete(new Error('test cleanup')) + await outcome + vi.mocked(readFile).mockReset() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts index 4c4dddf52e..20bfa289a2 100644 --- a/apps/desktop/tests/runtime-tree.spec.ts +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -1,8 +1,9 @@ -import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' +import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] @@ -14,32 +15,59 @@ function fixture(): string { } afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -it('verifies a runtime after relocation without depending on build paths', () => { +it('verifies a runtime after relocation without depending on build paths', async () => { const root = fixture() - const before = verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') + const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true }) - expect(desktopRuntimeId(verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) + expect(desktopRuntimeId(await verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) }) -it.each(['changed', 'extra', 'missing'])('rejects %s runtime bytes', (operation) => { +it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => { const dsh = join(fixture(), 'dsh') + const before = readDesktopRuntime(dsh, '1.0.0') if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') + if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') if (operation === 'missing') rmSync(join(dsh, 'package.json')) - expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/integrity/u) + expect(readDesktopRuntime(dsh, '1.0.0')).toEqual(before) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) -it('rejects filesystem links and incompatible targets', () => { +it('rejects filesystem links and incompatible targets', async () => { const dsh = join(fixture(), 'dsh') - expect(() => verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) + await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u) symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') - expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/unsupported filesystem/u) + expect(readDesktopRuntime(dsh, '1.0.0').release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u) }) -it('rejects a descriptor that maps a shared package outside node_modules', () => { +it.each(['missing', 'directory'])('rejects a %s Host entry during startup metadata loading', (operation) => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0]) + rmSync(path) + if (operation === 'directory') mkdirSync(path) + expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/missing Host file/u) +}) +it('rejects incompatible targets and shared package metadata during startup metadata loading', () => { + const dsh = join(fixture(), 'dsh') + expect(() => readDesktopRuntime(dsh, '2.0.0')).toThrow(/does not match Electron/u) + expect(() => readDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) + writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}') + expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package metadata mismatch/u) +}) +it('rejects a descriptor that maps a shared package outside node_modules', async () => { const dsh = join(fixture(), 'dsh') const path = join(dsh, DESKTOP_RUNTIME_FILE) const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] } descriptor.sharedPackages[0]!.path = '../outside' writeFileSync(path, JSON.stringify(descriptor)) - expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package record/u) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/shared package record/u) +}) +it('verifies recorded executable permissions only on Unix', async () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: { executable: boolean }[] } + descriptor.files[0]!.executable = !descriptor.files[0]!.executable + writeFileSync(path, JSON.stringify(descriptor)) + if (process.platform === 'win32') await expect(verifyDesktopRuntime(dsh, '1.0.0')).resolves.toMatchObject(descriptor) + else await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => { expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u) diff --git a/apps/desktop/tests/startup-renderer.spec.ts b/apps/desktop/tests/startup-renderer.spec.ts new file mode 100644 index 0000000000..dbbb180ca9 --- /dev/null +++ b/apps/desktop/tests/startup-renderer.spec.ts @@ -0,0 +1,134 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, onTestFinished, vi } from 'vitest' +import type { DesktopBackendState } from '../src/backend-controller.ts' +import type { DshDesktopStartupApi } from '../src/ipc.ts' +import { resolveDesktopLocale } from '../src/locale.ts' + +function startup(locale = 'en', status: Promise = Promise.resolve({ phase: 'starting' })) { + const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + onTestFinished(() => { + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + dom.window.close() + }) + const listeners = new Set<(state: DesktopBackendState) => void>() + const unsubscribe = vi.fn(() => { listeners.clear() }) + const retry = vi.fn(async () => {}) + const openPlugins = vi.fn(async () => {}) + const queried = Promise.withResolvers() + const api: DshDesktopStartupApi = { + protocolVersion: 1, + locale: async () => resolveDesktopLocale(locale), + backend: { + status: () => { queried.resolve(undefined); return status }, + retry, + subscribe: (listener) => { listeners.add(listener); return unsubscribe }, + }, + openPlugins, + } + Object.defineProperty(dom.window, 'dshDesktop', { value: api }) + runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + const document = dom.window.document + const element = (selector: string): HTMLElement => { + const result = document.querySelector(selector) + if (result === null) throw new Error(`Missing startup element: ${selector}`) + return result + } + const button = (selector: string): HTMLButtonElement => { + const result = document.querySelector(selector) + if (result === null) throw new Error(`Missing startup button: ${selector}`) + return result + } + const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) } + const copy = (): string => [element('#title').textContent, element('#description').textContent, + ...['#error', '#actions'].filter(selector => !element(selector).hidden) + .flatMap(selector => selector === '#actions' + ? [button('#retry').textContent, button('#plugins').textContent] + : [element(selector).textContent]), + ].join('\n') + return { dom, document, element, button, publish, copy, retry, openPlugins, unsubscribe, queried: queried.promise } +} + +it('shows English loading and recovery actions without a Host document', async () => { + const page = startup() + await expect.poll(() => page.element('#title').textContent).not.toBe('') + expect(page.copy()).toMatchInlineSnapshot(` + "Starting DeepSeek Harness… + Your workspace will open when it is ready." + `) + expect(page.element('main').getAttribute('aria-busy')).toBe('true') + expect(page.element('#spinner').hidden).toBe(false) + expect(page.element('#actions').hidden).toBe(true) + expect(page.button('#retry').disabled).toBe(true) + expect(page.button('#plugins').disabled).toBe(true) + page.publish({ phase: 'error', message: 'Plugin failed to load' }) + expect(page.copy()).toMatchInlineSnapshot(` + "DeepSeek Harness could not start + The application could not start. Retry or manage Desktop plugins to resolve the problem. + Plugin failed to load + Retry startup + Manage plugins" + `) + expect(page.element('main').getAttribute('aria-busy')).toBe('false') + expect(page.element('#spinner').hidden).toBe(true) + page.button('#plugins').click() + expect(page.openPlugins).toHaveBeenCalledOnce() + page.button('#retry').click() + expect(page.retry).toHaveBeenCalledOnce() + expect(page.element('#actions').hidden).toBe(true) + expect(page.element('#error').textContent).toBe('') + expect(page.element('main').getAttribute('aria-busy')).toBe('true') +}) + +it('shows Chinese loading and recovery copy', async () => { + const page = startup('zh-CN') + await expect.poll(() => page.element('#title').textContent).not.toBe('') + expect(page.document.documentElement.lang).toBe('zh-CN') + expect(page.copy()).toMatchInlineSnapshot(` + "正在启动 DeepSeek Harness… + 准备就绪后将自动打开工作区。" + `) + page.publish({ phase: 'error', message: '插件加载失败' }) + expect(page.copy()).toMatchInlineSnapshot(` + "DeepSeek Harness 无法启动 + 应用未能启动。你可以重试,或管理桌面插件以解决问题。 + 插件加载失败 + 重试启动 + 管理插件" + `) +}) + +it('renders diagnostic markup as text and exposes failures from recovery actions', async () => { + const page = startup() + await expect.poll(() => page.element('#title').textContent).not.toBe('') + const diagnostic = '' + page.publish({ phase: 'error', message: diagnostic }) + expect(page.element('#error').textContent).toBe(diagnostic) + expect(page.element('#error').childElementCount).toBe(0) + page.openPlugins.mockRejectedValueOnce(new page.dom.window.Error('Cannot open plugin manager')) + page.button('#plugins').click() + await expect.poll(() => page.element('#error').textContent).toBe('Cannot open plugin manager') + page.retry.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) + page.button('#retry').click() + await expect.poll(() => page.element('#error').textContent).toBe('Retry failed') + expect(page.button('#retry').disabled).toBe(false) + expect(page.button('#plugins').disabled).toBe(false) +}) + +it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => { + const initial = Promise.withResolvers() + const page = startup('en', initial.promise) + await page.queried + page.publish({ phase: 'error', message: 'Fresh startup failure' }) + initial.resolve({ phase: 'starting' }) + await initial.promise + expect(page.element('#error').textContent).toBe('Fresh startup failure') + expect(page.element('#actions').hidden).toBe(false) + page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) + expect(page.unsubscribe).toHaveBeenCalledOnce() + page.publish({ phase: 'starting' }) + expect(page.element('#error').textContent).toBe('Fresh startup failure') + page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) + expect(page.unsubscribe).toHaveBeenCalledOnce() +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6f7fa58c8d..d34cf1bf26 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,19 +12,16 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - { + ...(['preload', 'preload-app'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. - entry: { - preload: 'lib/types/preload.js', - 'preload-app': 'lib/types/preload-app.js', - }, + entry: { [name]: `lib/types/${name}.js` }, outDir: 'lib', - format: ['cjs'], - platform: 'node', + format: ['cjs'] as const, + platform: 'node' as const, target: 'es2024', fixedExtension: false, dts: false, clean: false, deps: { neverBundle: ['electron'] }, - }, + })), ]) diff --git a/package.json b/package.json index 1c4c8f6f86..662e7f8935 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "package:desktop:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64", "package:desktop:mac:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64:dir", "package:desktop:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64", + "package:desktop:win:x64:unsigned": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:unsigned", "package:desktop:win:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:dir", "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64", "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64", From d09b4accd31ccd1d19b62ae9c5881d41de0270e2 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 9 Sep 2026 21:10:02 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=E6=89=93=E5=8C=85=20+=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 2 + ...ectron-desktop-packaging-and-updates.zh.md | 2 + ...led-runtime-and-external-plugins.i18n.yaml | 4 +- ...op-bundled-runtime-and-external-plugins.md | 4 +- ...bundled-runtime-and-external-plugins.zh.md | 4 +- ...desktop-build-release-validation.i18n.yaml | 6 + ...-09-09-desktop-build-release-validation.md | 23 ++ ...-09-desktop-build-release-validation.zh.md | 23 ++ ...mmediate-window-and-direct-start.i18n.yaml | 4 +- ...sktop-immediate-window-and-direct-start.md | 4 + ...op-immediate-window-and-direct-start.zh.md | 6 +- ...6-09-09-desktop-in-place-profile.i18n.yaml | 6 + .../2026-09-09-desktop-in-place-profile.md | 25 ++ .../2026-09-09-desktop-in-place-profile.zh.md | 25 ++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 18 +- apps/desktop/README.zh.md | 18 +- apps/desktop/renderer/startup.html | 7 +- apps/desktop/renderer/startup.js | 30 ++- apps/desktop/scripts/dev.ts | 7 +- apps/desktop/src/backend-controller.ts | 10 +- apps/desktop/src/ipc.ts | 7 + apps/desktop/src/locale.ts | 20 +- apps/desktop/src/main.ts | 153 ++++++++++-- apps/desktop/src/paths.ts | 8 +- apps/desktop/src/preload-app.ts | 4 + apps/desktop/src/profile-packages.ts | 45 ++-- apps/desktop/src/project-manager.ts | 221 +++++++----------- apps/desktop/src/runtime-tree.ts | 64 +++-- apps/desktop/src/startup-document.ts | 25 ++ apps/desktop/src/startup-error.ts | 31 +++ apps/desktop/tests/backend-controller.spec.ts | 8 +- .../expected/startup-en-configuration.txt | 8 + .../tests/expected/startup-en-plugins.txt | 8 + .../tests/expected/startup-en-reinstall.txt | 8 + .../tests/expected/startup-en-restart.txt | 8 + .../expected/startup-zh-CN-configuration.txt | 8 + .../tests/expected/startup-zh-CN-plugins.txt | 8 + .../expected/startup-zh-CN-reinstall.txt | 8 + .../tests/expected/startup-zh-CN-restart.txt | 8 + apps/desktop/tests/main-startup.spec.ts | 138 ++++++++++- apps/desktop/tests/plugin-pnpm.spec.ts | 4 +- apps/desktop/tests/preload-app.spec.ts | 5 + apps/desktop/tests/profile-packages.spec.ts | 9 +- apps/desktop/tests/project-manager.spec.ts | 188 ++++++++++----- apps/desktop/tests/runtime-tree.spec.ts | 53 ++++- apps/desktop/tests/startup-renderer.spec.ts | 84 +++++-- 48 files changed, 979 insertions(+), 388 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md create mode 100644 apps/desktop/src/startup-document.ts create mode 100644 apps/desktop/src/startup-error.ts create mode 100644 apps/desktop/tests/expected/startup-en-configuration.txt create mode 100644 apps/desktop/tests/expected/startup-en-plugins.txt create mode 100644 apps/desktop/tests/expected/startup-en-reinstall.txt create mode 100644 apps/desktop/tests/expected/startup-en-restart.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-configuration.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-plugins.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-reinstall.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-restart.txt diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index d182ff8d4a..494f94041f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: f6aa73d6e708db04c6978690b8fc2b7eb70f270b -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 00c1346ab9b0bca1f704d74e7792acd03c5df9bf +2026-08-25-electron-desktop-packaging-and-updates.md: 2e266b74c2c1a5de62312ddce2c43de0908b2675 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 0a48e80b47450db7e45ab96f3a7b6e465ca271b6 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index f6aa73d6e7..2e266b74c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) +Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. + ## Problem DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 00c1346ab9..0a48e80b47 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 +本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 + ## 问题 DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 8c5be1e7e1..5c992fd72d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a662af570c017d6fd3d38cf066c0a02e0049a84f -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 6284949f6747cbe47a5de149ff598354c9b9cc58 +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 9fcee1d49d4d2595fa9dfc929b58ee3e5262ac54 +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: f448029cbf840e8003ce26d67788663168629da4 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index a662af570c..9fcee1d49d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) +Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. + ## Problem Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades. @@ -44,7 +46,7 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s ## Alternatives considered -Full runtime verification belongs to packaging. Startup reads the descriptor, validates release and target compatibility, checks shared manifests and required Host entries, and uses the recorded runtime identity for profile reuse. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. +Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index 6284949f67..f448029cbf 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 +本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 + ## 问题 Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。 @@ -44,7 +46,7 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 考虑过的替代方案 -完整运行时验证属于打包流程。启动读取描述文件,验证发布版本与目标平台的兼容性,检查共享包 manifest 和必要的 Host 入口,并使用记录的运行时身份复用 profile。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 +完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml new file mode 100644 index 0000000000..c41d31830d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.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-09-desktop-build-release-validation.md +2026-09-09-desktop-build-release-validation.md: 23aeeaa98d2647f8b9f54e94ea2aedaa82b19e55 +2026-09-09-desktop-build-release-validation.zh.md: 266bca5bbd593907c9a8fadd43050113ede94100 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md new file mode 100644 index 0000000000..23aeeaa98d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md @@ -0,0 +1,23 @@ +# Agent Note: Verify Desktop release compatibility during packaging + +Status: implemented + +English | [中文](2026-09-09-desktop-build-release-validation.zh.md) + +## Problem + +The shell and runtime descriptor ship together. Comparing their release facts on every launch repeats packaging checks without proving that installed executable bytes match the descriptor. + +## Decision + +The packaging verifier owns descriptor schema, shell version, platform, architecture, declared Host protocol version, and Node/pnpm semver validation. Startup reads the fields needed for profile preparation and retains shared-package record and Host-entry checks. The actual Host ready message still validates its protocol version. + +This partially supersedes startup release compatibility checks in the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). That note retains package ownership and distribution rationale. + +## Alternatives considered + +Repeating descriptor comparisons can diagnose a mixed installation earlier, but cannot establish executable integrity. Reintroducing them requires a concrete installation failure that packaging validation and actual Host diagnostics cannot adequately explain. + +## Consequences + +Startup does not reject a descriptor solely because its declared release schema, target, or Host protocol differs, or its Node/pnpm version strings are not semver. Packaging still rejects these cases and shell-version mismatch. Tests distinguish startup reads from packaging verification. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md new file mode 100644 index 0000000000..266bca5bbd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 在打包时验证 Desktop 发布兼容性 + +Status: implemented + +[English](2026-09-09-desktop-build-release-validation.md) | 中文 + +## 问题 + +壳与运行时描述文件一起发布。每次启动比较其中的发布信息会重复打包检查,却不能证明已安装的可执行文件字节与描述文件一致。 + +## 决策 + +打包验证器负责描述文件 schema、shell 版本、平台、架构、声明的 Host 协议版本,以及 Node/pnpm semver 验证。启动读取准备 profile 所需的字段,并保留共享包记录和 Host 入口检查。实际 Host ready 消息仍然验证其协议版本。 + +本决策部分取代[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的启动发布兼容性检查。该记录继续负责包归属与分发的理由。 + +## 考虑过的替代方案 + +重复比较描述文件能更早诊断混装,但不能证明可执行文件完整性。重新引入这些比较需要具体的安装故障,且打包验证和实际 Host 诊断无法充分解释该故障。 + +## 后果 + +启动不会仅因描述文件声明的发布 schema、目标或 Host 协议不同,或 Node/pnpm 版本字符串不是 semver 而拒绝运行。打包仍拒绝这些情况和 shell 版本不匹配。测试区分启动读取与打包验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml index 9a562164c3..e26d213c6e 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-desktop-immediate-window-and-direct-start.md -2026-09-09-desktop-immediate-window-and-direct-start.md: 19443bb22e5fa3e50ce027b474cd439928db9992 -2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 0d79638d331bb8b66a80f37f532b36ebb1e65316 +2026-09-09-desktop-immediate-window-and-direct-start.md: 7491e4cedf058bee4dc81b7734f54bc4caf6aa72 +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 37ec49e737cd5f765ab97de8c534bb08b230a45d diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md index 19443bb22e..7491e4cedf 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) +Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. + ## Problem Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process. @@ -12,6 +14,8 @@ Waiting for backend readiness leaves users without a window during profile prepa Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit. +The main window owns recovery because the failed Host cannot supply its own controls. Every error page retains diagnostics and exposes restart, disable-third-party-plugins, and reset-Desktop actions, plus reinstallation guidance. The actions remain available regardless of error classification. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The lock lives inside the profile, and reset preserves its directory so another transaction cannot acquire a replacement lock during cleanup. Disabled third-party package files cannot block startup when no third-party bundles are enabled. Self-contained shell recovery controls use intercepted form navigation so a failed preload cannot disable them. + Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes. This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md index 0d79638d33..37ec49e737 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -4,13 +4,17 @@ Status: implemented [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 +本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 + ## 问题 等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。 ## 决策 -Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断、重试和插件管理操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 +Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断和恢复操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 + +主窗口负责恢复,因为故障 Host 无法提供自己的操作界面。每个错误页保留诊断,提供重启、禁用第三方插件和重置 Desktop 三个操作,以及重装提示。所有操作均不依赖错误分类而保持可用。重置删除 profile 内除当前持有的锁之外的全部内容,不留备份;共享产品数据和 Harness home 环境文件保持不变。锁位于 profile 内,重置保留目录,防止其他事务在清理期间取得替代锁。没有启用第三方 bundle 时,已禁用插件的文件不能阻止启动。独立内嵌错误页通过拦截表单导航执行恢复,因此 preload 故障不会使按钮失效。 profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml new file mode 100644 index 0000000000..25f423496f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.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-09-desktop-in-place-profile.md +2026-09-09-desktop-in-place-profile.md: c83bb9826a4068f699e330f911c52064790b08b6 +2026-09-09-desktop-in-place-profile.zh.md: 481dcc11d6c4dcc033f68186c88bfd96028fdbe8 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md new file mode 100644 index 0000000000..c83bb9826a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md @@ -0,0 +1,25 @@ +# Agent Note: Modify the Desktop profile in place + +Status: implemented + +English | [中文](2026-09-09-desktop-in-place-profile.zh.md) + +## Problem + +Staging preserves an old plugin installation but adds profile copying, directory moves, a recovery journal, and rollback state. Local plugin changes accept explicit repair after failure instead of this complexity. + +## Decision + +Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking, dependency validation, and approved native builds remain. Compatible upgrades refresh links without copying plugin files. + +Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted. + +This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active. + +## Alternatives considered + +Staging protects the previous installation at the cost of copying and crash recovery. Versioned directories still need preparation, selection, and cleanup. Direct writes give up automatic recovery; reintroduction requires an unattended-recovery product requirement that justifies these costs. + +## Consequences + +Tests cover offline initialization, in-place upgrades, failure before writes, retained changes after Host failure, partial pnpm failure, restored host links, and exclusive package ownership. Signed application and GUI acceptance remain release-environment checks. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md new file mode 100644 index 0000000000..481dcc11d6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 直接修改 Desktop profile + +Status: implemented + +[English](2026-09-09-desktop-in-place-profile.md) | 中文 + +## 问题 + +staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢复日志和回滚状态。本地插件变更接受失败后显式修复,以避免这些复杂度。 + +## 决策 + +Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁、依赖验证和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。 + +包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。 + +本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。 + +## 考虑过的替代方案 + +staging 以复制和崩溃恢复为代价保护旧安装。版本化目录仍需要准备、选择和清理。直接写入放弃自动恢复;只有无人值守恢复的产品要求能证明这些成本合理时,才重新引入。 + +## 后果 + +测试覆盖离线初始化、原地升级、写入前失败、Host 失败后保留修改、pnpm 部分失败、宿主链接恢复及独占包操作。签名应用和 GUI 验收仍由发布环境负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index c2b596749a..007cd91375 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: 86ef7d9eb63f3b7aee89a86b5c3a4866af034ac5 -README.zh.md: ea8976b880a5d5a3df5932c700237505d5f19d5a +README.md: 91cdd6d4ea0e001dd9ae81dc9f8e64aa5a912879 +README.zh.md: bd1381eb08477b1541b584eca7c5c5cb7c3f1cac diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 86ef7d9eb6..91cdd6d4ea 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,7 +14,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no | Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. | -| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes prepare in staging and start the actual backend after profile replacement; a journal and one rollback profile cover failed or interrupted activation. | +| Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. | | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions. @@ -29,17 +29,19 @@ Electron chooses typed English or Chinese shell copy from its application locale ### Runtime and plugin activation -The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup checks release metadata, shared package manifests, and required Host entry files without enumerating or hashing the runtime tree. Full file integrity verification runs during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. +The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. 1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once. -2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, and checks enabled plugins’ peer requirements. Plugin versions and lockfile remain unchanged; pnpm does not run. +2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run. 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. -5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. The actual backend starts after replacement; failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. +5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error; repair the installation and retry. Desktop creates no staging directories, activation journals, or rollback copies. -The loading page does not depend on the Host. Startup failures appear in the same main window with retry and plugin-management actions. Closing during loading waits for the pending child to exit. The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. +The loading page does not depend on the Host. Every startup and backend error page retains diagnostics and offers three actions: restart the application, disable all third-party plugins and restart the Host, or reset Desktop and restart the Host. Reinstallation guidance is always visible for missing or damaged application files. Disabling plugins retains their files and skips their installation graph when none are enabled. The plugin manager remains available through the application menu. Runtime compatibility is checked before any backend starts; plugin changes have no automatic rollback. -Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. +Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the same three actions and diagnostics; its controls do not require preload. + +Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. ## Develop @@ -57,7 +59,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation, staging, and rollback paths. +Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package @@ -182,7 +184,7 @@ Signed packaging emits generic-provider channel metadata for the deployment sele ## Low-level development overrides -`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index ea8976b880..bd1381eb08 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,7 +14,7 @@ | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 | | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 | -| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先在 staging 准备,替换 profile 后启动实际后端;失败或中断的激活由事务日志和一个 rollback profile 恢复。 | +| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 | | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。 @@ -29,17 +29,19 @@ Electron 根据应用 locale 选择类型化的中英文字典,并以英文作 ### 运行时与插件激活 -签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动检查发布元数据、共享包 manifest 和必要的 Host 入口文件,不枚举运行时目录树或计算其文件哈希。完整的文件完整性验证在打包时执行。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 +签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest 和共享包链接,然后激活并启动一次实际后端。 -2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,并检查已启用插件的 peer 要求。插件版本和锁文件保持不变;不运行 pnpm。 +2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 -5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。替换后启动实际后端;激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 +5. 插件变更在修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败保留已修改文件并报告错误;修复安装后重试。Desktop 不创建 staging 目录、激活日志或回滚副本。 -加载页不依赖 Host。启动失败在同一个主窗口显示,并提供重试和插件管理操作。加载期间关闭应用会等待正在启动的子进程退出。后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 +加载页不依赖 Host。每个启动和后端错误页均保留诊断,并提供三个操作:重启应用、禁用全部第三方插件并重启 Host、重置 Desktop 并重启 Host。页面始终显示应用文件缺失或损坏时的重装提示。禁用插件会保留文件;没有启用的第三方插件时,不再检查它们的安装依赖图。插件管理仍可从应用菜单打开。每次启动后端前都会检查运行时兼容性;插件变更不自动回滚。 -包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 +重置会删除 `$DSH_HOME/profiles/desktop` 内除当前持有的事务锁之外的全部内容,再初始化内置 profile。Desktop 配置和已安装第三方插件会被删除,不保留备份。共享任务、设置和 Harness home 的 `.env` 不受影响。壳资源或 preload 故障使用独立内嵌页面,保留相同的三个操作和诊断;其按钮不依赖 preload。 + +包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 ## 开发 @@ -57,7 +59,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 +Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 @@ -182,7 +184,7 @@ pnpm run prepare:desktop ## 底层开发覆盖项 -`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 diff --git a/apps/desktop/renderer/startup.html b/apps/desktop/renderer/startup.html index e0980a5b49..098efb4f92 100644 --- a/apps/desktop/renderer/startup.html +++ b/apps/desktop/renderer/startup.html @@ -12,10 +12,13 @@

+ + diff --git a/apps/desktop/renderer/startup.js b/apps/desktop/renderer/startup.js index ce8c64361a..036b950b0a 100644 --- a/apps/desktop/renderer/startup.js +++ b/apps/desktop/renderer/startup.js @@ -4,8 +4,11 @@ async function main() { const { id, messages } = await api.locale() document.documentElement.lang = id document.querySelector('#page-title').textContent = messages.startupLoading - document.querySelector('#retry').textContent = messages.retry - document.querySelector('#plugins').textContent = messages.managePlugins + document.querySelector('#restart').textContent = messages.restartApplication + document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins + document.querySelector('#reset-configuration').textContent = messages.resetConfiguration + document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice + document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice function render(state) { const failed = state.phase === 'error' document.querySelector('main').setAttribute('aria-busy', String(!failed)) @@ -15,23 +18,26 @@ async function main() { document.querySelector('#error').hidden = !failed document.querySelector('#error').textContent = failed ? state.message : '' document.querySelector('#actions').hidden = !failed - document.querySelector('#retry').disabled = !failed - document.querySelector('#plugins').disabled = !failed + for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed + document.querySelector('#reset-advice').hidden = !failed + document.querySelector('#reinstall-advice').hidden = !failed } let changed = false const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) }) window.addEventListener('pagehide', unsubscribe, { once: true }) const initial = await api.backend.status() if (!changed) render(initial) - document.querySelector('#retry').addEventListener('click', async () => { + async function recover(operation) { render({ phase: 'starting' }) - try { await api.backend.retry() } - catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } - }) - document.querySelector('#plugins').addEventListener('click', async () => { - try { await api.openPlugins() } - catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } - }) + try { await operation() } + catch (error) { + const current = await api.backend.status().catch(() => undefined) + render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) }) + } + } + document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) }) + document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) }) + document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) }) } void main() diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 3ae65d12aa..85bea68633 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -53,7 +53,7 @@ async function runPackageScript(script: string, cwd: string): Promise { await run(process.execPath, [packageManager, 'run', script], cwd) } -async function launchElectron(projectDir: string): Promise { +async function launchElectron(): Promise { const require = createRequire(import.meta.url) const electron: unknown = require('electron') if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable') @@ -65,7 +65,6 @@ async function launchElectron(projectDir: string): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, - DSH_DESKTOP_DEV_PROJECT_DIR: projectDir, DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_NODE_BINARY: process.execPath, DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', @@ -102,14 +101,14 @@ async function main(): Promise { nodeVersion: process.versions.node, pnpmVersion, } - const projectDir = prepareDevelopmentProject({ + prepareDevelopmentProject({ projectDir: join(DEVELOPMENT_ROOT, 'project'), cliDir: join(REPOSITORY_ROOT, 'apps', 'cli'), hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), release, }) - await launchElectron(projectDir) + await launchElectron() } main().catch((error: unknown) => { diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts index 03c49369ab..91850ef926 100644 --- a/apps/desktop/src/backend-controller.ts +++ b/apps/desktop/src/backend-controller.ts @@ -1,7 +1,9 @@ /** Owns one backend startup and its quiescent teardown independently of windows. */ +import { desktopErrorState, type DesktopRecovery } from './startup-error.ts' + /** Backend availability presented by the desktop window. */ -export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string } +export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly recovery?: DesktopRecovery } /** Child lifecycle owned by the desktop backend controller. */ export interface DesktopBackendHost { @@ -78,7 +80,7 @@ export class DesktopBackendController { try { await this.cleanup(attempt) } catch (cleanupError) { if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') } - if (!cancelled) this.update({ phase: 'error', message: failure instanceof Error ? failure.message : String(failure) }) + if (!cancelled) this.update(desktopErrorState(failure)) throw failure } }).finally(() => { if (this.pending === pending) this.pending = undefined }) @@ -130,9 +132,9 @@ export class DesktopBackendController { if (this.current.phase !== 'ready') return attempt.cancelled = true const cleanup = this.cleanup(attempt) - this.update({ phase: 'error', message: error.message }) + this.update(desktopErrorState(error)) void cleanup.catch((cleanupError: unknown) => { - if (this.attempt === attempt) this.update({ phase: 'error', message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) }) + if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) }) } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 7ac65344ea..88735a2ad4 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -16,6 +16,9 @@ export const DESKTOP_IPC = { pluginsDisableAll: 'dsh-desktop:plugins-disable-all', backendStatus: 'dsh-desktop:backend-status', backendRetry: 'dsh-desktop:backend-retry', + applicationRestart: 'dsh-desktop:application-restart', + applicationClose: 'dsh-desktop:application-close', + configurationReset: 'dsh-desktop:configuration-reset', backendState: 'dsh-desktop:backend-state', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', @@ -56,4 +59,8 @@ export interface DshDesktopApi { /** Startup-page controls, unavailable to backend-provided application documents. */ export interface DshDesktopStartupApi extends Pick { openPlugins(): Promise + disablePlugins(): Promise + restart(): Promise + close(): Promise + resetConfiguration(): Promise } diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index c937474beb..142d263d3c 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -5,7 +5,15 @@ export const en = { startupFailed: 'DeepSeek Harness could not start', startupLoading: 'Starting DeepSeek Harness…', startupLoadingDescription: 'Your workspace will open when it is ready.', - startupErrorDescription: 'The application could not start. Retry or manage Desktop plugins to resolve the problem.', + startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.', + startupRestartAdvice: 'Close and restart the application. If the problem continues, keep the error details for troubleshooting.', + startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', + startupPluginsAdvice: 'A third-party plugin may be preventing startup. Disable all third-party plugins and try again. Plugin files and tasks are retained.', + startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.', + restartApplication: 'Close and restart', + closeApplication: 'Close application', + resetConfiguration: 'Reset Desktop and retry', + disableThirdPartyPlugins: 'Disable all third-party plugins and retry', managePlugins: 'Manage plugins', pluginsMenu: 'Desktop Plugins…', pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', @@ -55,7 +63,15 @@ export const zh = { startupFailed: 'DeepSeek Harness 无法启动', startupLoading: '正在启动 DeepSeek Harness…', startupLoadingDescription: '准备就绪后将自动打开工作区。', - startupErrorDescription: '应用未能启动。你可以重试,或管理桌面插件以解决问题。', + startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。', + startupRestartAdvice: '请关闭并重启应用。如果问题持续出现,请保留错误详情以便排查。', + startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', + startupPluginsAdvice: '第三方插件可能导致应用无法启动。请禁用全部第三方插件后重试。插件文件和任务数据会保留。', + startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。', + restartApplication: '关闭并重启', + closeApplication: '关闭应用', + resetConfiguration: '重置 Desktop 并重试', + disableThirdPartyPlugins: '禁用全部第三方插件并重试', managePlugins: '管理插件', pluginsMenu: '桌面插件…', pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f132afb3b2..012f444e03 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,9 +20,26 @@ import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' +import { desktopErrorState, DesktopStartupError } from './startup-error.ts' +import { startupFailureDocument } from './startup-document.ts' const SCHEME = 'dsh-app' let focusPrimaryWindow = (): void => {} +type RecoveryAction = 'restart' | 'plugins' | 'reset' +const emergencyPages = new WeakMap() +let recoverApplication = (action: RecoveryAction): Promise => { + if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application')) + app.relaunch() + app.quit() + return Promise.resolve() +} + +async function showEmergencyDocument(window: BrowserWindow, message: string): Promise { + const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message) + const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}` + emergencyPages.set(window, { url, message, busy: false }) + await window.loadURL(url) +} function errorOf(reason: unknown, fallback: string): Error { return reason instanceof Error ? reason : new Error(fallback) @@ -63,13 +80,6 @@ function runtimeResources(): RuntimeResources { return { node, pnpm, dsh } } -function developmentProject(): string | undefined { - const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR - if (configured === undefined || configured === '') return undefined - if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications') - return resolve(configured) -} - function developmentHostInspectPort(enabled: boolean): number | undefined { const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT if (!enabled || configured === undefined || configured === '') return undefined @@ -98,6 +108,14 @@ function createWindow(preload: string, show = false): BrowserWindow { window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) window.webContents.on('will-navigate', (event, url) => { if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault() + const page = emergencyPages.get(window) + if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return + const action = new URL(url) + if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return + page.busy = true + void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => { + if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`) + }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false }) }) return window } @@ -134,11 +152,10 @@ async function serveShellAsset(request: Request): Promise { async function main(): Promise { const resources = runtimeResources() const paths = resolveDesktopPaths() - const development = developmentProject() + const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project') const activeProject = development ?? paths.profile - const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - let pageError: string | undefined + let pageError: Extract | undefined let quitting = false let startup: Promise | undefined let mainWindow: BrowserWindow | undefined @@ -152,10 +169,19 @@ async function main(): Promise { const startupUrl = `${SCHEME}://shell/startup.html` const applicationUrl = `${SCHEME}://app/index.html` let navigation: { window: BrowserWindow; url: string; promise: Promise } | undefined + let emergencyDocument = false + + const showEmergencyError = async (error: unknown): Promise => { + if (quitting || emergencyDocument) return + emergencyDocument = true + const diagnostic = desktopErrorState(error).message + pageError = { phase: 'error', message: diagnostic, recovery: 'reinstall' } + if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic) + } const navigateMain = (url: string): Promise => { const window = mainWindow - if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve() + if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve() if (navigation?.window === window && navigation.url === url) return navigation.promise const next = { window, url, promise: Promise.resolve() } next.promise = window.loadURL(url).catch((error: unknown) => { @@ -167,7 +193,7 @@ async function main(): Promise { return next.promise } const backendState = (): DesktopBackendState => pageError === undefined - ? backend.state : { phase: 'error', message: pageError } + ? backend.state : pageError const publishBackend = (state: DesktopBackendState): void => { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send(DESKTOP_IPC.backendState, state) @@ -175,9 +201,32 @@ async function main(): Promise { } const backend = new DesktopBackendController((onFailure) => { if (development === undefined) manager.assertProfileRuntime(activeProject) - return new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, onFailure) + const hostInspectPort = developmentHostInspectPort(development !== undefined) + const pluginsEnabled = development === undefined && manager.hasEnabledPlugins() + const classify = (error: Error): Error => { + if (error instanceof DesktopStartupError) return error + if ((error.message.includes(resources.node) || error.message.includes(resources.dsh)) + && /ENOENT|Cannot find|failed to (?:read|parse) overlay/u.test(error.message)) { + return new DesktopStartupError('reinstall', error) + } + if (error.message.includes('only the launching environment may set') + || /failed to (?:read|parse) (?:overlay|patches)|must be a top-level YAML array|patchReload must be/u.test(error.message)) { + return new DesktopStartupError('configuration', error) + } + return pluginsEnabled ? new DesktopStartupError('plugins', error) : error + } + const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, + (error) => { onFailure(classify(error)) }) + return { + async start() { + try { return await host.start() } + catch (error) { throw classify(errorOf(error, messages.startupFailed)) } + }, + stop: () => host.stop(), + fetch: (request: Request) => host.fetch(request), + } }, (state) => { - if (state.phase === 'starting') pageError = undefined + if (state.phase === 'starting' && !emergencyDocument) pageError = undefined publishBackend(backendState()) if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) }) }) @@ -191,14 +240,34 @@ async function main(): Promise { } const hooks: DesktopProjectHooks = { - beforeActivate: () => backend.stop(), - afterActivate: () => backend.start(async () => {}), + beforeChange: () => backend.stop(), + afterChange: () => backend.start(async () => {}), + } + + recoverApplication = async (action): Promise => { + await startup?.catch(() => undefined) + await backend.stop() + if (action === 'restart') { + app.relaunch() + app.quit() + return + } + if (development !== undefined) throw new Error('Desktop profile recovery requires a packaged application') + if (action === 'reset') await manager.resetConfiguration(hooks) + else await manager.mutate({ type: 'plugins-disable-all' }, hooks) + emergencyDocument = false + pageError = undefined + navigation = undefined + await navigateMain(applicationUrl) } const showStartupError = async (error: unknown): Promise => { if (quitting) return - pageError = errorOf(error, messages.startupFailed).message - await navigateMain(startupUrl) + pageError = desktopErrorState(error) + try { await navigateMain(startupUrl) } + catch (navigationError) { + await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed)) + } publishBackend(backendState()) } const reconcileBackend = (): Promise => { @@ -207,7 +276,7 @@ async function main(): Promise { await navigateMain(startupUrl) await backend.start(async () => { if (development === undefined) { - await manager.applyRelease(app.getVersion(), { beforeActivate: async () => {}, afterActivate: async () => {} }) + await manager.applyRelease() } }) if (backend.host !== undefined) await navigateMain(applicationUrl) @@ -228,7 +297,13 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) - if (url.hostname === 'shell') return serveShellAsset(request) + if (url.hostname === 'shell') return serveShellAsset(request).then((response) => { + if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) { + void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`)) + .catch((error: unknown) => { console.error(error) }) + } + return response + }) if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 })) const active = backend.host if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) @@ -288,6 +363,32 @@ async function main(): Promise { await reconcileBackend() focusPrimaryWindow() }) + ipcMain.handle(DESKTOP_IPC.applicationClose, (event) => { + assertDesktopSender(event, ['shell']) + app.quit() + }) + ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => { + assertDesktopSender(event, ['shell']) + try { + await recoverApplication('restart') + } catch (error) { + await showStartupError(error) + } + }) + ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) throw new Error('Desktop configuration reset requires a packaged application') + const failure = backendState() + if (failure.phase !== 'error') { + throw new Error('Desktop profile reset requires a startup failure') + } + await startup?.catch(() => undefined) + try { + await recoverApplication('reset') + } catch (error) { + await showStartupError(error) + } + }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { assertDesktopSender(event, ['shell']) return updates.check() @@ -375,6 +476,13 @@ async function main(): Promise { const window = createWindow(appPreload, true) mainWindow = window window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) + window.webContents.on('preload-error', (_event, _path, error) => { + void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) }) + }) + window.webContents.on('render-process-gone', (_event, details) => { + void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`)) + .catch((failure: unknown) => { console.error(failure) }) + }) return window } focusPrimaryWindow = () => { @@ -425,6 +533,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk if (diagnosticFile !== undefined) { await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined) } - dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message) - app.exit(1) + const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true) + window.once('closed', () => { app.quit() }) + await showEmergencyDocument(window, message) }) diff --git a/apps/desktop/src/paths.ts b/apps/desktop/src/paths.ts index 4f883929db..5727e5c013 100644 --- a/apps/desktop/src/paths.ts +++ b/apps/desktop/src/paths.ts @@ -7,9 +7,6 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' export interface DesktopPaths { readonly root: string readonly profile: string - readonly staging: string - readonly rollback: string - readonly pending: string readonly lock: string readonly pnpm: { readonly root: string @@ -32,10 +29,7 @@ export function resolveDesktopPaths(dshHome: string = resolveDshHome()): Desktop return { root, profile: join(dshHome, 'profiles', 'desktop'), - staging: join(root, 'staging'), - rollback: join(root, 'rollback', 'profile'), - pending: join(root, 'pending.json'), - lock: join(root, 'lock'), + lock: join(dshHome, 'profiles', 'desktop', 'lock'), pnpm: { root: pnpm, store: join(pnpm, 'store'), diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 140a7369a4..65c55ccf11 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -17,6 +17,10 @@ const startup: DshDesktopStartupApi = { }, }, openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise, + disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, + restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise, + close: () => ipcRenderer.invoke(DESKTOP_IPC.applicationClose) as Promise, + resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise, } contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell' diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts index 0ec5ca573e..18aa4ffaf6 100644 --- a/apps/desktop/src/profile-packages.ts +++ b/apps/desktop/src/profile-packages.ts @@ -1,10 +1,11 @@ /** Desktop-owned host links and validation of the external plugin dependency graph. */ import { createHash } from 'node:crypto' -import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { satisfies } from 'semver' +import { DesktopStartupError } from './startup-error.ts' import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts' /** Applied runtime identity and the only links Desktop may replace. */ @@ -52,6 +53,11 @@ function inside(root: string, path: string): boolean { * @returns Validated state, or undefined for an uninitialized profile. */ export function readDesktopProfileState(profile: string): DesktopProfileState | undefined { + try { return readProfileState(profile) } + catch (error) { throw new DesktopStartupError('configuration', error) } +} + +function readProfileState(profile: string): DesktopProfileState | undefined { const path = join(profile, DESKTOP_PROFILE_STATE) if (!existsSync(path)) return undefined const value: unknown = JSON.parse(readFileSync(path, 'utf8')) @@ -85,7 +91,7 @@ export function desktopPluginLockHash(profile: string): string { /** * Remove only recorded host links, without following even broken targets. - * @param profile - Active or copied profile. + * @param profile - Desktop profile. */ export function unlinkDesktopHostPackages(profile: string): void { for (const link of readDesktopProfileState(profile)?.links ?? []) { @@ -99,25 +105,6 @@ export function unlinkDesktopHostPackages(profile: string): void { } } -/** - * Copy plugin files without copying host packages or sharing writable hardlinks. - * @param source - Active Desktop profile. - * @param target - Empty transaction profile directory. - */ -export function copyDesktopProfile(source: string, target: string): void { - const owned = readDesktopProfileState(source)?.links ?? [] - for (const link of owned) { - const path = join(source, 'node_modules', link.name) - const entry = stat(path) - if (entry !== undefined && (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target))) { - throw new Error(`desktop profile: refusing to copy unowned package ${link.name}`) - } - } - const links = new Set(owned.map(link => join(source, 'node_modules', link.name))) - cpSync(source, target, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true, - filter: path => !links.has(path) }) -} - /** * Bind an external profile to this application's real package directories. * @param profile - Candidate profile. @@ -191,10 +178,14 @@ export function validateDesktopPluginGraph( profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], ): void { const profileRoot = realpathSync.native(profile) - const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync.native(runtimePath(root, entry.path))])) + const shared = new Map(runtime.sharedPackages.map((entry) => { + try { return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const } + catch (error) { throw new DesktopStartupError('reinstall', error) } + })) for (const [name, path] of shared) { if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) } + if (activePlugins.length === 0) return const scanned = new Set() const scan = (modules: string): void => { if (!existsSync(modules)) return @@ -247,8 +238,12 @@ export function validateDesktopPluginGraph( } } for (const name of activePlugins) { - const path = packageFrom(profile, name) - if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) - visit(path, name) + try { + const path = packageFrom(profile, name) + if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) + visit(path, name) + } catch (error) { + throw new DesktopStartupError('plugins', error) + } } } diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index f6759775e1..d87623dcba 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,8 +1,7 @@ -/** Transactional owner of the reserved desktop profile and its private pnpm state. */ +/** In-place owner of the reserved desktop profile and its private pnpm state. */ import { valid } from 'semver' import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { existsSync, fsyncSync, @@ -12,13 +11,13 @@ import { openSync, closeSync, readFileSync, + readdirSync, realpathSync, - renameSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs' -import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { delimiter, dirname, join, resolve, sep } from 'node:path' import { DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, @@ -27,9 +26,10 @@ import { import type { DesktopPaths } from './paths.ts' import { removeOwnedDirectory } from './owned-directory.ts' import type { DesktopRelease } from './release.ts' +import { DesktopStartupError } from './startup-error.ts' import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' import { - copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, + desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, } from './profile-packages.ts' @@ -53,16 +53,6 @@ interface DesktopProjectManifest { } } -/** Journaled activation step used for crash recovery. */ -interface DesktopPendingTransaction { - readonly schemaVersion: 1 - readonly id: string - readonly stagingProfile: string - readonly fromRuntimeId: string | null - readonly toRuntimeId: string - readonly step: 'prepared' | 'active-moved' | 'staging-activated' -} - /** Exact executables the desktop shell bundles. */ export interface DesktopRuntimeExecutables { readonly node: string @@ -70,12 +60,12 @@ export interface DesktopRuntimeExecutables { readonly dsh: string } -/** Hooks that bind project replacement to the active backend lifecycle. */ +/** Hooks that stop the backend before profile writes and restart it after success. */ export interface DesktopProjectHooks { - /** Stop the active backend and await process exit before directory moves. */ - beforeActivate(): Promise - /** Start the selected active project after commit or rollback. */ - afterActivate(): Promise + /** Stop the active backend and await process exit before modifying its files. */ + beforeChange(): Promise + /** Start the modified profile after package preparation succeeds. */ + afterChange(): Promise } /** Supported dependency mutation. */ @@ -124,11 +114,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function isDescendant(root: string, target: string): boolean { - const child = relative(root, target) - return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child) -} - function assertPackageName(name: string): void { if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`) } @@ -163,6 +148,11 @@ export function packageNameFromSpec(spec: string): string | undefined { } function projectManifest(projectDir: string): DesktopProjectManifest { + try { return readProjectManifest(projectDir) } + catch (error) { throw new DesktopStartupError('configuration', error) } +} + +function readProjectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined @@ -183,11 +173,11 @@ function projectManifest(projectDir: string): DesktopProjectManifest { function profilePluginNames(projectDir: string): readonly string[] { const bundles = projectManifest(projectDir).dsh.profile.bundles if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) { - throw new Error('desktop project: profile must begin with the built-in desktop bundle list') + throw new DesktopStartupError('configuration', new Error('desktop project: profile must begin with the built-in desktop bundle list')) } const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length) if (new Set(bundles).size !== bundles.length) { - throw new Error('desktop project: profile bundle list contains a duplicate package') + throw new DesktopStartupError('configuration', new Error('desktop project: profile bundle list contains a duplicate package')) } for (const plugin of plugins) assertPackageName(plugin) return plugins @@ -234,7 +224,7 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) } } -/** Transactional desktop npm project manager. */ +/** Desktop npm project manager with direct writes and no rollback. */ export class DesktopProjectManager { private lockDescriptor: number | undefined private descriptor: DesktopRuntimeDescriptor | undefined @@ -248,50 +238,38 @@ export class DesktopProjectManager { readonly runtime: DesktopRuntimeExecutables, ) {} - /** Recover an interrupted directory replacement before reading the active project. */ - recover(): void { - if (!existsSync(this.paths.pending)) return - const value = readJson(this.paths.pending) - if (!isRecord(value) || value.schemaVersion !== 1 - || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string' - || !isDescendant(this.paths.staging, value.stagingProfile) - || value.stagingProfile !== join(this.paths.staging, value.id, 'profile') - || (value.fromRuntimeId !== null && (typeof value.fromRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fromRuntimeId))) - || typeof value.toRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.toRuntimeId) - || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) { - throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`) - } - const pending: DesktopPendingTransaction = { - schemaVersion: 1, - id: value.id, - stagingProfile: value.stagingProfile, - fromRuntimeId: value.fromRuntimeId, - toRuntimeId: value.toRuntimeId, - step: value.step, - } - if (pending.step === 'staging-activated' && !existsSync(pending.stagingProfile) && existsSync(this.paths.profile)) { - if (readDesktopProfileState(this.paths.profile)?.runtimeId !== pending.toRuntimeId) { - throw new Error('desktop project: activated profile does not match its journal') - } - removeOwnedDirectory(this.paths.profile) - } - if (!existsSync(this.paths.profile) && pending.fromRuntimeId !== null && existsSync(this.paths.rollback)) { - if (readDesktopProfileState(this.paths.rollback)?.runtimeId !== pending.fromRuntimeId) { - throw new Error('desktop project: rollback profile does not match its journal') - } - mkdirSync(dirname(this.paths.profile), { recursive: true }) - renameSync(this.paths.rollback, this.paths.profile) - } - removeOwnedDirectory(pending.stagingProfile) - unlinkSync(this.paths.pending) - } - /** Read the active desktop plugin inventory. */ listPlugins(): readonly DesktopPluginRecord[] { if (!existsSync(this.paths.profile)) return [] return pluginRecords(this.paths.profile) } + /** @returns Whether the profile enables any third-party bundle, without loading plugin files. */ + hasEnabledPlugins(): boolean { + return existsSync(this.paths.profile) && profilePluginNames(this.paths.profile).length > 0 + } + + /** + * Reinitialize the profile, deleting configuration and third-party packages without a backup. + * @param hooks - Stop the Host before resetting files; restart after preparation succeeds. + * @returns Completion of reset; the held lock and shared product data are preserved. + */ + async resetConfiguration(hooks: DesktopProjectHooks): Promise { + await this.withLock(async () => { + await hooks.beforeChange() + this.descriptor = this.readRuntime() + for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) { + const path = join(this.paths.profile, entry.name) + if (path === this.paths.lock) continue + if (entry.isDirectory()) removeOwnedDirectory(path) + else unlinkSync(path) + } + createPluginProfile(this.paths.profile) + this.prepareProfile(this.paths.profile) + await hooks.afterChange() + }) + } + /** Read the dsh version supplied by this application's verified resources. */ dshVersion(): string { return this.currentRuntime().release.version @@ -316,6 +294,11 @@ export class DesktopProjectManager { return this.descriptor } + private readRuntime(): DesktopRuntimeDescriptor { + try { return readDesktopRuntime(this.runtime.dsh) } + catch (error) { throw new DesktopStartupError('reinstall', error) } + } + private prepareProfile(projectDir: string): void { const runtime = this.currentRuntime() linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime) @@ -323,14 +306,13 @@ export class DesktopProjectManager { } /** Read release metadata and reconcile its external profile without installing core packages. */ - async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise { + async applyRelease(): Promise { return this.withLock(async () => { - this.recover() - const target = readDesktopRuntime(this.runtime.dsh, electronVersion) + const target = this.readRuntime() this.descriptor = target const previous = readDesktopProfileState(this.paths.profile) - if (existsSync(this.paths.profile) && previous === undefined) { - throw new Error('desktop project: existing profile is not a Desktop plugin profile') + if (previous === undefined && readdirSync(this.paths.profile).some(name => join(this.paths.profile, name) !== this.paths.lock)) { + throw new DesktopStartupError('configuration', new Error('desktop project: existing profile is not a Desktop plugin profile')) } if (previous?.runtimeId === desktopRuntimeId(target) && previous.lockHash === desktopPluginLockHash(this.paths.profile) @@ -341,37 +323,38 @@ export class DesktopProjectManager { validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } - const stagingProfile = this.newStagingProfile() - try { - if (previous === undefined) createPluginProfile(stagingProfile) - else copyDesktopProfile(this.paths.profile, stagingProfile) - await this.reconcileProfile(stagingProfile, previous) - await this.activate(stagingProfile, hooks) - return true - } catch (error) { - removeOwnedDirectory(stagingProfile) - throw error - } + if (previous === undefined) createPluginProfile(this.paths.profile) + await this.reconcileProfile(this.paths.profile, previous) + return true }) } - /** Apply an exact plugin dependency or activation change through a staging project. */ + /** Modify the current profile while its backend is stopped; failures retain partial changes. */ async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { await this.withLock(async () => { - this.recover() this.currentRuntime() if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - const stagingProfile = this.newStagingProfile() - try { - copyDesktopProfile(this.paths.profile, stagingProfile) - await this.applyMutation(stagingProfile, mutation) - await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile), - mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all') - await this.activate(stagingProfile, hooks) - } catch (error) { - removeOwnedDirectory(stagingProfile) - throw error + await hooks.beforeChange() + if (mutation.type === 'plugins-disable-all') { + const manifest = projectManifest(this.paths.profile) + writeJson(join(this.paths.profile, 'package.json'), { + ...manifest, + dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + }) + this.prepareProfile(this.paths.profile) + await hooks.afterChange() + return } + const previous = readDesktopProfileState(this.paths.profile) + const packagesChanged = mutation.type !== 'plugin-toggle' + if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile) + try { + await this.applyMutation(this.paths.profile, mutation) + } finally { + if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime()) + } + await this.reconcileProfile(this.paths.profile, previous, packagesChanged) + await hooks.afterChange() }) } @@ -394,13 +377,7 @@ export class DesktopProjectManager { this.prepareProfile(projectDir) } - private newStagingProfile(): string { - const path = join(this.paths.staging, randomUUID(), 'profile') - mkdirSync(path, { recursive: true, mode: 0o700 }) - return path - } - - private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise { + private async applyMutation(projectDir: string, mutation: Exclude): Promise { switch (mutation.type) { case 'plugin-add': { const requestedName = packageNameFromSpec(mutation.spec) @@ -442,9 +419,6 @@ export class DesktopProjectManager { ) } return - case 'plugins-disable-all': - writeProfilePlugins(projectDir, pluginRecords(projectDir).map(plugin => ({ ...plugin, enabled: false }))) - return case 'plugin-toggle': { assertPackageName(mutation.name) const plugins = pluginRecords(projectDir) @@ -459,42 +433,6 @@ export class DesktopProjectManager { } } - private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise { - const pending: DesktopPendingTransaction = { - schemaVersion: 1, - id: basename(dirname(stagingProfile)), - stagingProfile, - fromRuntimeId: readDesktopProfileState(this.paths.profile)?.runtimeId ?? null, - toRuntimeId: desktopRuntimeId(this.currentRuntime()), - step: 'prepared', - } - writeJson(this.paths.pending, pending) - let activeMoved = false - let stagingActivated = false - try { - await hooks.beforeActivate() - removeOwnedDirectory(this.paths.rollback) - mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 }) - writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction) - if (existsSync(this.paths.profile)) { - renameSync(this.paths.profile, this.paths.rollback) - activeMoved = true - } - mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 }) - writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction) - renameSync(stagingProfile, this.paths.profile) - stagingActivated = true - await hooks.afterActivate() - unlinkSync(this.paths.pending) - } catch (error) { - if (stagingActivated) removeOwnedDirectory(this.paths.profile) - if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile) - if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending) - await hooks.afterActivate().catch(() => undefined) - throw error - } - } - private async runPnpm(projectDir: string, args: readonly string[]): Promise { const [command, ...commandArgs] = args if (command === undefined) throw new Error('desktop project: pnpm command is required') @@ -586,7 +524,8 @@ export class DesktopProjectManager { } private async withLock(operation: () => Promise): Promise { - mkdirSync(this.paths.root, { recursive: true, mode: 0o700 }) + mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 }) + if (lstatSync(this.paths.profile).isSymbolicLink()) throw new Error('desktop project: profile directory must not be a link') let descriptor: number try { descriptor = openSync(this.paths.lock, 'wx', 0o600) diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts index 8f02a46497..30b083bbbb 100644 --- a/apps/desktop/src/runtime-tree.ts +++ b/apps/desktop/src/runtime-tree.ts @@ -1,11 +1,11 @@ /** Relocatable, integrity-recorded production packages carried by one Desktop release. */ import { createHash } from 'node:crypto' -import { existsSync, lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' +import { lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, sep } from 'node:path' import { promisify } from 'node:util' import { valid } from 'semver' -import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts' +import { DESKTOP_HOST_PACKAGE } from './core-package-set.ts' import { parseDesktopRelease, type DesktopRelease } from './release.ts' /** Descriptor at the root of the immutable Desktop resource tree. */ @@ -143,22 +143,21 @@ export function writeDesktopRuntime( } /** - * Read release metadata and check shared manifests and Host entries without scanning runtime contents. + * Read packaged metadata and check shared package records. * @param root - Current application's runtime resources. - * @param electronVersion - Expected shell version. - * @param target - Required execution target; defaults to the current process. - * @returns Validated runtime descriptor. + * @returns Runtime metadata whose release compatibility is verified during packaging. */ -export function readDesktopRuntime( - root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, -): DesktopRuntimeDescriptor { +export function readDesktopRuntime(root: string): DesktopRuntimeDescriptor { const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) - if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch + if (!record(value) || typeof value.platform !== 'string' || typeof value.arch !== 'string' || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) { - throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + throw new Error('desktop runtime: invalid descriptor') } - const release = parseDesktopRelease(value.release) - if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + if (!record(value.release) || typeof value.release.version !== 'string' + || typeof value.release.nodeVersion !== 'string' || typeof value.release.pnpmVersion !== 'string') { + throw new Error('desktop runtime: invalid release fields') + } + const release = value.release as unknown as DesktopRelease const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => { if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name) || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) { @@ -169,34 +168,14 @@ export function readDesktopRuntime( if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) { throw new Error('desktop runtime: duplicate shared package') } - const files = value.files.map((entry: unknown): DesktopRuntimeFile => { - if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number' - || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean' - || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) { - throw new Error('desktop runtime: invalid file inventory') - } - runtimePath(root, entry.path) - return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable } - }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) - for (const entry of sharedPackages) { - const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) - if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { - throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) - } - } + const files = value.files as DesktopRuntimeFile[] for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) { if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) { throw new Error(`desktop runtime: missing or mismatched ${name}`) } } - for (const file of DESKTOP_HOST_RUNTIME_FILES) { - const path = `node_modules/${DESKTOP_HOST_PACKAGE}/${file}` - if (!files.some(entry => entry.path === path) || !existsSync(runtimePath(root, path)) - || !lstatSync(runtimePath(root, path)).isFile()) { - throw new Error(`desktop runtime: missing Host file ${file}`) - } - } - return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files } + return { schemaVersion: value.schemaVersion as 1, release, platform: value.platform as NodeJS.Platform, + arch: value.arch, sharedPackages, files } } /** @@ -209,7 +188,18 @@ export function readDesktopRuntime( export async function verifyDesktopRuntime( root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, ): Promise { - const descriptor = readDesktopRuntime(root, electronVersion, target) + const descriptor = readDesktopRuntime(root) + if (descriptor.schemaVersion !== 1 || descriptor.platform !== target.platform || descriptor.arch !== target.arch) { + throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + } + const release = parseDesktopRelease(descriptor.release) + if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + for (const entry of descriptor.sharedPackages) { + const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) + if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { + throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) + } + } const actual = await inventoryRuntimeForVerification(root) // Windows has no portable Unix executable permission bits. const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' diff --git a/apps/desktop/src/startup-document.ts b/apps/desktop/src/startup-document.ts new file mode 100644 index 0000000000..65245649e2 --- /dev/null +++ b/apps/desktop/src/startup-document.ts @@ -0,0 +1,25 @@ +/** Self-contained recovery document for an unavailable shell renderer or preload. */ + +import type { DesktopLocale } from './locale.ts' + +/** + * Render escaped diagnostics without depending on application resource files. + * @param locale - Shell-owned translations. + * @param message - Failure details displayed as plain text. + * @returns An HTML document suitable for an isolated emergency window. + */ +export function startupFailureDocument(locale: DesktopLocale, message: string): string { + const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') + return ` + +${escape(locale.messages.startupFailed)} + +

${escape(locale.messages.startupFailed)}

${escape(locale.messages.startupReinstallAdvice)}

+

${escape(locale.messages.startupConfigurationAdvice)}

+
${escape(message)}
+ +
+
+
` +} diff --git a/apps/desktop/src/startup-error.ts b/apps/desktop/src/startup-error.ts new file mode 100644 index 0000000000..feb19c0dda --- /dev/null +++ b/apps/desktop/src/startup-error.ts @@ -0,0 +1,31 @@ +/** Recovery guidance attached by the owner of a failed Desktop startup operation. */ + +/** Actions that can restore an unavailable Desktop backend. */ +export type DesktopRecovery = 'restart' | 'reinstall' | 'plugins' | 'configuration' + +/** An operation failure with recovery guidance independent of its diagnostic wording. */ +export class DesktopStartupError extends Error { + /** + * @param recovery - Recovery supported by the failed operation. + * @param cause - Original diagnostic, retained for troubleshooting. + */ + constructor(readonly recovery: DesktopRecovery, cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + } +} + +/** + * Preserve diagnostics and recovery guidance when sending failures to a renderer. + * @param error - Startup or runtime failure. + * @returns Serializable error state; unclassified failures suggest restarting. + */ +export function desktopErrorState(error: unknown): { + phase: 'error' + message: string + recovery: DesktopRecovery +} { + const message = error instanceof AggregateError + ? [error.message, ...error.errors.map(item => desktopErrorState(item).message)].join('\n') + : error instanceof Error ? error.message : String(error) + return { phase: 'error', message, recovery: error instanceof DesktopStartupError ? error.recovery : 'restart' } +} diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts index abdc78a9e7..c955ddc1e3 100644 --- a/apps/desktop/tests/backend-controller.spec.ts +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -83,7 +83,7 @@ describe('desktop backend controller', () => { it('publishes preparation errors and permits retry', async () => { const f = fixture() await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') - expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile', recovery: 'restart' }) expect(f.create).not.toHaveBeenCalled() f.ready.resolve() await f.controller.start(async () => {}) @@ -101,7 +101,7 @@ describe('desktop backend controller', () => { await f.stopping.promise f.exited.resolve() await rejected - expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed', recovery: 'restart' }) expect(f.host.stop).toHaveBeenCalledTimes(1) await f.controller.close() }) @@ -111,7 +111,7 @@ describe('desktop backend controller', () => { f.ready.resolve() await f.controller.start(async () => {}) f.fail(new Error('transport failed')) - expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed', recovery: 'restart' }) expect(f.controller.host).toBeUndefined() await f.stopping.promise const prepare = vi.fn(async () => {}) @@ -135,7 +135,7 @@ describe('desktop backend controller', () => { await f.stopping.promise f.exited.resolve() await rejected - expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure', recovery: 'restart' }]) await f.controller.close() }) diff --git a/apps/desktop/tests/expected/startup-en-configuration.txt b/apps/desktop/tests/expected/startup-en-configuration.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-configuration.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-plugins.txt b/apps/desktop/tests/expected/startup-en-plugins.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-plugins.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-reinstall.txt b/apps/desktop/tests/expected/startup-en-reinstall.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-reinstall.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-restart.txt b/apps/desktop/tests/expected/startup-en-restart.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-restart.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-zh-CN-configuration.txt b/apps/desktop/tests/expected/startup-zh-CN-configuration.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-configuration.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-plugins.txt b/apps/desktop/tests/expected/startup-zh-CN-plugins.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-plugins.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt b/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-restart.txt b/apps/desktop/tests/expected/startup-zh-CN-restart.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-restart.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 252db0a18c..65a651d45e 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { DESKTOP_IPC } from '../src/ipc.ts' const harness = await vi.hoisted(async () => { @@ -12,6 +13,7 @@ const harness = await vi.hoisted(async () => { const windows: FakeWindow[] = [] const hosts: FakeHost[] = [] const handlers = new Map unknown>() + let pluginsEnabled = false let preparing = deferred() let prepared = deferred() let hostStarted = deferred() @@ -24,6 +26,7 @@ const harness = await vi.hoisted(async () => { readonly webContents = Object.assign(new EventEmitter(), { setWindowOpenHandler: vi.fn(), openDevTools: vi.fn(), + getURL: () => this.urls.at(-1) ?? '', send: vi.fn((channel: string, state: { phase?: string }) => { if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve() }), @@ -51,16 +54,18 @@ const harness = await vi.hoisted(async () => { this.ready.reject(new Error('child stopped')) return this.exited.promise }) - constructor(..._args: unknown[]) { hosts.push(this) } + constructor(readonly node: string, readonly runtime: string, readonly profile: string) { hosts.push(this) } } const app = Object.assign(new EventEmitter(), { - isPackaged: false, + isPackaged: true, name: 'Desktop test', whenReady: () => Promise.resolve(), getLocale: () => 'en-US', getVersion: () => '1.0.0', + getAppPath: () => 'desktop-test-app', requestSingleInstanceLock: () => true, exit: vi.fn(), + relaunch: vi.fn(), quit: vi.fn(() => { const event = { preventDefault: vi.fn() } app.emit('before-quit', event) @@ -71,12 +76,17 @@ const harness = await vi.hoisted(async () => { windows, hosts, handlers, app, FakeWindow, FakeHost, dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), + assertProfileRuntime: vi.fn(), get preparing() { return preparing }, get prepared() { return prepared }, get hostStarted() { return hostStarted }, get navigated() { return navigated }, get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted }, nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, + get pluginsEnabled() { return pluginsEnabled }, + set pluginsEnabled(value: boolean) { pluginsEnabled = value }, reset() { windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + app.isPackaged = true + pluginsEnabled = false preparing = deferred(); prepared = deferred(); hostStarted = deferred() navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred() }, @@ -97,7 +107,16 @@ vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desk vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { readonly applyRelease = harness.applyRelease - readonly assertProfileRuntime = vi.fn() + readonly assertProfileRuntime = harness.assertProfileRuntime + hasEnabledPlugins() { return harness.pluginsEnabled } + async mutate(_mutation: unknown, hooks: { beforeChange(): Promise; afterChange(): Promise }) { + await hooks.beforeChange() + harness.pluginsEnabled = false + await hooks.afterChange() + } + async resetConfiguration(hooks: { beforeChange(): Promise; afterChange(): Promise }) { + await this.mutate(undefined, hooks) + } }, })) vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost })) @@ -117,7 +136,8 @@ beforeEach(() => { vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node') vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') - vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined) + vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' }) + vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) }) afterEach(async () => { @@ -128,9 +148,98 @@ afterEach(async () => { vi.clearAllTimers() vi.useRealTimers() vi.unstubAllEnvs() + vi.unstubAllGlobals() }) describe('desktop main startup', () => { + it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + const started = harness.nextHostStart() + const event = { preventDefault: vi.fn() } + window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`) + await harness.hosts[0]!.stopping.promise + harness.hosts[0]!.exited.resolve() + await started + harness.hosts[1]!.ready.resolve() + await harness.navigated.promise + expect(event.preventDefault).toHaveBeenCalled() + expect(window.urls.at(-1)).toBe('dsh-app://app/index.html') + }) + + it('allows a full profile reset for an unclassified startup failure', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.exited.resolve() + harness.hosts[0]!.ready.reject(new Error('Unknown startup failure')) + await harness.errorPublished.promise + const started = harness.nextHostStart() + const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset)) + await started + harness.hosts[1]!.ready.resolve() + await reset + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('keeps a self-contained reinstall document in the main window after preload failure', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + expect(window.urls.at(-1)).toContain('data:text/html') + expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable') + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + expect(harness.windows).toHaveLength(1) + expect(window.urls.at(-1)).toContain('data:text/html') + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('offers plugin recovery and disables plugins before restarting in the same window', async () => { + harness.pluginsEnabled = true + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.exited.resolve() + harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed')) + await harness.errorPublished.promise + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ recovery: 'plugins' }) + const nextStarted = harness.nextHostStart() + const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll)) + await nextStarted + expect(harness.pluginsEnabled).toBe(false) + harness.hosts[1]!.ready.resolve() + await recovery + expect(harness.windows).toHaveLength(1) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('waits for Host exit before relaunching the application', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart)) + await harness.hosts[0]!.stopping.promise + expect(harness.app.relaunch).not.toHaveBeenCalled() + harness.hosts[0]!.exited.resolve() + await restart + expect(harness.app.relaunch).toHaveBeenCalledOnce() + }) + it('shows the loading window before profile preparation and starts one actual Host', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -148,12 +257,31 @@ describe('desktop main startup', () => { harness.hosts[0]!.ready.resolve() await Promise.all([retry, secondRetry, harness.navigated.promise]) expect(harness.applyRelease).toHaveBeenCalledTimes(1) + expect(harness.assertProfileRuntime).toHaveBeenCalledWith('desktop-test-profile') + expect(harness.hosts[0]).toMatchObject({ + node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'), + runtime: join('desktop-test-resources', 'dsh'), + profile: 'desktop-test-profile', + }) expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) expect(harness.windows).toHaveLength(1) expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html']) expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) }) + it('starts the unpackaged Host from the application development directory', async () => { + harness.app.isPackaged = false + await import('../src/main.ts') + await harness.hostStarted.promise + const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project') + expect(harness.hosts[0]).toMatchObject({ node: 'test-node', runtime: project, profile: project }) + expect(harness.applyRelease).not.toHaveBeenCalled() + expect(harness.assertProfileRuntime).not.toHaveBeenCalled() + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + it('keeps startup errors and a successful retry in the same window', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -165,7 +293,7 @@ describe('desktop main startup', () => { first.ready.reject(new Error('plugin composition failed')) await harness.errorPublished.promise await failedRetry - expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' }) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', recovery: 'restart' }) expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html']) const nextStarted = harness.nextHostStart() const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts index f565967cee..351387160e 100644 --- a/apps/desktop/tests/plugin-pnpm.spec.ts +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -47,8 +47,8 @@ it('installs a real pnpm graph, then executes approved scripts with the shared h const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) - const hooks: DesktopProjectHooks = { beforeActivate: async () => {}, afterActivate: async () => {} } - await manager.applyRelease('1.0.0', hooks) + const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} } + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string } diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 81eac02cc3..d6e7a63b07 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -23,8 +23,13 @@ it('provides startup controls and a removable state subscription to shell docume await api.backend.status() await api.backend.retry() await api.openPlugins() + await api.disablePlugins() + await api.resetConfiguration() + await api.restart() + await api.close() expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen], + [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], [DESKTOP_IPC.applicationClose], ]) const listener = vi.fn() const dispose = api.backend.subscribe(listener) diff --git a/apps/desktop/tests/profile-packages.spec.ts b/apps/desktop/tests/profile-packages.spec.ts index 4f3fe1308a..d419c962f2 100644 --- a/apps/desktop/tests/profile-packages.spec.ts +++ b/apps/desktop/tests/profile-packages.spec.ts @@ -1,11 +1,11 @@ import { execFileSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, expect, it } from 'vitest' import { createPluginProfile } from '../src/project-manager.ts' -import { copyDesktopProfile, linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import { linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' import { runtimeFixture, writePackage } from './runtime-fixture.ts' const roots: string[] = [] @@ -58,12 +58,9 @@ it('refuses to satisfy a plugin dependency from an ancestor CLI project', () => writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } }) expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u) }) -it('copies writable plugin bytes independently and removes broken owned links without following them', () => { +it('removes broken owned links without following them', () => { const { root, profile } = fixture() writePackage(join(profile, 'node_modules'), 'plugin') - copyDesktopProfile(profile, join(root, 'copy')) - writeFileSync(join(root, 'copy/node_modules/plugin/index.js'), 'changed') - expect(readFileSync(join(profile, 'node_modules/plugin/index.js'), 'utf8')).toContain('identity') rmSync(join(root, 'dsh'), { recursive: true }) expect(() =>{ unlinkDesktopHostPackages(profile) }).not.toThrow() }) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 9d141efa2d..cb4fee6389 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,11 +1,10 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts' -import { readDesktopProfileState } from '../src/profile-packages.ts' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] @@ -48,7 +47,7 @@ if (command !== 'rebuild') { return path } function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } + return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides } } function setup(): { root: string; manager: DesktopProjectManager } { const root = temporaryRoot() @@ -63,6 +62,85 @@ function calls(root: string): { args: string[]; registry: string }[] { afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) describe('desktop external plugin profile', () => { + it('starts with disabled plugins even when their installed manifests are corrupt', async () => { + const { manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json') + writeFileSync(manifest, '{broken') + await expect(manager.applyRelease()).rejects.toThrow() + await manager.mutate({ type: 'plugins-disable-all' }, hooks()) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(readFileSync(manifest, 'utf8')).toBe('{broken') + await manager.resetConfiguration(hooks()) + expect(existsSync(manifest)).toBe(false) + await expect(manager.applyRelease()).resolves.toBe(false) + }) + + it('disables every third-party bundle without reading a broken plugin patch declaration', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml') + unlinkSync(patch) + await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { + expect(manager.hasEnabledPlugins()).toBe(false) + } })) + expect(manager.hasEnabledPlugins()).toBe(false) + expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) + expect(calls(root)).toHaveLength(2) + await expect(manager.applyRelease()).resolves.toBe(false) + }) + + it('resets the entire profile without backups while retaining its lock and shared data', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const profile = manager.paths.profile + expect(manager.paths.lock).toBe(join(profile, 'lock')) + const task = join(root, '.dsh', 'task-sentinel') + const homeEnvironment = join(root, '.dsh', '.env') + writeFileSync(homeEnvironment, 'HOME_SETTING=retained') + writeFileSync(task, 'retained task') + writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken') + writeFileSync(join(profile, 'cordis.patch.yml'), ': broken') + writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad') + mkdirSync(join(profile, '.extra')) + writeFileSync(join(profile, '.extra', 'custom-file'), 'remove') + const shared = join(root, 'shared-data') + mkdirSync(shared) + writeFileSync(join(shared, 'sentinel'), 'preserve') + symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir') + await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'configuration' }) + await manager.resetConfiguration(hooks({ + beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') }, + afterChange: async () => { + manager.assertProfileRuntime(profile) + expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid)) + await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active') + }, + })) + expect(manager.listPlugins()).toEqual([]) + expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false) + expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false) + expect(existsSync(join(profile, '.env'))).toBe(false) + expect(existsSync(join(profile, '.extra'))).toBe(false) + expect(existsSync(join(profile, 'external-link'))).toBe(false) + expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve') + expect(readFileSync(task, 'utf8')).toBe('retained task') + expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained') + expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false) + expect(calls(root)).toHaveLength(2) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(existsSync(homeEnvironment)).toBe(true) + }) + + it('reports damaged application metadata as a reinstall failure', async () => { + const { manager } = setup() + writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken') + await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'reinstall' }) + }) + it('accepts registry names and tags but rejects alternate sources and flags', () => { expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin') expect(packageNameFromSpec('plugin@next')).toBe('plugin') @@ -73,9 +151,8 @@ describe('desktop external plugin profile', () => { it('initializes and restarts offline without executing pnpm', async () => { const { root, manager } = setup() - await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) - await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) - await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false) + await expect(manager.applyRelease()).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(false) expect(manager.listPlugins()).toEqual([]) expect(calls(root)).toEqual([]) expect(existsSync(manager.paths.pnpm.store)).toBe(false) @@ -85,21 +162,17 @@ describe('desktop external plugin profile', () => { it('repairs a removed managed link without running pnpm', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')) - await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(true) expect(calls(root)).toEqual([]) }) it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => { const { manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() }) - let started = false - await expect(relaunched.applyRelease('1.0.0', hooks({ - afterActivate: async () => { started = true }, - }))).resolves.toBe(false) - expect(started).toBe(false) + await expect(relaunched.applyRelease()).resolves.toBe(false) }) it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { @@ -108,22 +181,16 @@ describe('desktop external plugin profile', () => { if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json')) - let starts = 0 - await expect(manager.applyRelease('1.0.0', hooks({ - afterActivate: async () => { starts++ }, - }))).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(true) const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) - await expect(relaunched.applyRelease('1.0.0', hooks({ - afterActivate: async () => { starts++ }, - }))).resolves.toBe(false) - expect(starts).toBe(1) + await expect(relaunched.applyRelease()).resolves.toBe(false) expect(existsSync(manager.paths.profile)).toBe(true) expect(calls(root)).toEqual([]) }) it('installs only plugins and checks the graph before running lifecycle scripts', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ @@ -136,7 +203,7 @@ describe('desktop external plugin profile', () => { it('retains disabled plugin versions through updates and enables them explicitly', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) await manager.mutate({ type: 'plugins-disable-all' }, hooks()) expect(calls(root)).toHaveLength(2) @@ -151,30 +218,29 @@ describe('desktop external plugin profile', () => { it('keeps plugin files and patches through a compatible release and application relocation', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') const nextRoot = join(root, 'relocated', 'dsh') runtimeFixture(nextRoot, '1.1.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) - await expect(next.applyRelease('1.1.0', hooks())).resolves.toBe(true) + await expect(next.applyRelease()).resolves.toBe(true) expect(next.listPlugins()).toEqual(manager.listPlugins()) expect(next.releaseVersion()).toBe('1.1.0') expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') expect(calls(root)).toHaveLength(2) expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis'))) - writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only') expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') }) it('reinstalls the locked plugin graph when bundled Node changes', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) const dsh = join(root, 'new-node') runtimeFixture(dsh, '1.1.0', '24.18.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) - await next.applyRelease('1.1.0', hooks()) + await next.applyRelease() expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'], ]) @@ -183,60 +249,70 @@ describe('desktop external plugin profile', () => { it('allows incompatible plugins to be disabled in recovery without deleting them', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) const dsh = join(root, 'next-major') runtimeFixture(dsh, '2.0.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) - await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u) - expect(next.releaseVersion()).toBe('1.0.0') - expect(() =>{ next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u) + await expect(next.applyRelease()).rejects.toThrow(/requires @deepseek-ai\/cordis/u) + expect(next.releaseVersion()).toBe('2.0.0') await next.mutate({ type: 'plugins-disable-all' }, hooks()) expect(next.releaseVersion()).toBe('2.0.0') expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) }) - it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { + it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => { const { manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() let starts = 0 await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') }, - afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') }, + beforeChange: async () => { + expect(manager.listPlugins()).toEqual([]) + if (phase === 'before') throw new Error('before failed') + }, + afterChange: async () => { starts++; throw new Error('after failed') }, }))).rejects.toThrow(`${phase} failed`) - expect(manager.listPlugins()).toEqual([]) - expect(existsSync(manager.paths.pending)).toBe(false) + expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }]) + expect(starts).toBe(phase === 'before' ? 0 : 1) + expect(existsSync(join(manager.paths.root, 'staging'))).toBe(false) + expect(existsSync(join(manager.paths.root, 'rollback'))).toBe(false) + expect(existsSync(join(manager.paths.root, 'pending.json'))).toBe(false) }) - it('recovers a directory move using recorded runtime identities', async () => { - const { manager } = setup() - await manager.applyRelease('1.0.0', hooks()) - const id = readDesktopProfileState(manager.paths.profile)?.runtimeId - const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile') - mkdirSync(stagingProfile, { recursive: true }) - mkdirSync(dirname(manager.paths.rollback), { recursive: true }) - renameSync(manager.paths.profile, manager.paths.rollback) - writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' })) - manager.recover() - expect(manager.releaseVersion()).toBe('1.0.0') - expect(existsSync(stagingProfile)).toBe(false) - expect(existsSync(manager.paths.pending)).toBe(false) + it('keeps partial package changes and restores host links after pnpm fails', async () => { + const { root, manager } = setup() + await manager.applyRelease() + const failingPnpm = join(root, 'failing.mjs') + writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) + await worker.applyRelease() + let starts = 0 + await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ + afterChange: async () => { starts++ }, + }))).rejects.toThrow(/pnpm exited with 1/u) + expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + expect(starts).toBe(0) + expect(existsSync(manager.paths.lock)).toBe(false) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))) + .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis'))) + await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) + expect(manager.listPlugins()).toEqual([]) }) it('holds the transaction lock until the pnpm worker exits', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() const ready = join(root, 'ready') const release = join(root, 'release') const blocker = join(root, 'blocking.mjs') writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) - await worker.applyRelease('1.0.0', hooks()) + await worker.applyRelease() const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) try { await expect.poll(() => existsSync(ready)).toBe(true) expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) - await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u) + await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u) } finally { writeFileSync(release, 'continue') await pending diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts index 20bfa289a2..04a5145d00 100644 --- a/apps/desktop/tests/runtime-tree.spec.ts +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -23,34 +23,67 @@ it('verifies a runtime after relocation without depending on build paths', async }) it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => { const dsh = join(fixture(), 'dsh') - const before = readDesktopRuntime(dsh, '1.0.0') + const before = readDesktopRuntime(dsh) if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') if (operation === 'missing') rmSync(join(dsh, 'package.json')) - expect(readDesktopRuntime(dsh, '1.0.0')).toEqual(before) + expect(readDesktopRuntime(dsh)).toEqual(before) await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) it('rejects filesystem links and incompatible targets', async () => { const dsh = join(fixture(), 'dsh') await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u) symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') - expect(readDesktopRuntime(dsh, '1.0.0').release.version).toBe('1.0.0') + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u) }) -it.each(['missing', 'directory'])('rejects a %s Host entry during startup metadata loading', (operation) => { +it('reads file inventory records unchanged during startup', () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: unknown[] } + descriptor.files.unshift({ path: '../outside', bytes: -1.5, sha256: 'unchecked', executable: 'unchecked' }) + writeFileSync(path, JSON.stringify(descriptor)) + expect(readDesktopRuntime(dsh).files).toEqual(descriptor.files) +}) +it.each(['missing', 'directory'])('checks a %s Host entry only during build verification', async (operation) => { const dsh = join(fixture(), 'dsh') const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0]) rmSync(path) if (operation === 'directory') mkdirSync(path) - expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/missing Host file/u) + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) -it('rejects incompatible targets and shared package metadata during startup metadata loading', () => { +it('checks the shell version only during build verification', async () => { const dsh = join(fixture(), 'dsh') - expect(() => readDesktopRuntime(dsh, '2.0.0')).toThrow(/does not match Electron/u) - expect(() => readDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) - writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}') - expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package metadata mismatch/u) + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '2.0.0')).rejects.toThrow(/does not match Electron/u) +}) +it.each([ + { schemaVersion: 2 }, + { platform: 'other' }, + { arch: 'other' }, + { release: { schemaVersion: 2 } }, + { release: { hostProtocolVersion: 999 } }, + { release: { nodeVersion: 'invalid' } }, + { release: { pnpmVersion: 'invalid' } }, +])('checks release compatibility only during build verification: %j', async (patch) => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const original = readDesktopRuntime(dsh) + const descriptor = { ...original, ...patch, release: { ...original.release, ...patch.release } } + writeFileSync(path, JSON.stringify(descriptor)) + expect(readDesktopRuntime(dsh)).toEqual(descriptor) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/invalid|incompatible/u) +}) +it.each(['missing', 'invalid-json', 'mismatched'])('checks %s shared manifests only during build verification', async (operation) => { + const dsh = join(fixture(), 'dsh') + const before = readDesktopRuntime(dsh) + const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json') + if (operation === 'missing') rmSync(path) + else writeFileSync(path, operation === 'invalid-json' ? '{' : '{}') + expect(readDesktopRuntime(dsh)).toEqual(before) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow() }) it('rejects a descriptor that maps a shared package outside node_modules', async () => { const dsh = join(fixture(), 'dsh') diff --git a/apps/desktop/tests/startup-renderer.spec.ts b/apps/desktop/tests/startup-renderer.spec.ts index dbbb180ca9..584c9f9587 100644 --- a/apps/desktop/tests/startup-renderer.spec.ts +++ b/apps/desktop/tests/startup-renderer.spec.ts @@ -1,10 +1,12 @@ import { readFileSync } from 'node:fs' import { runInContext } from 'node:vm' +import { fileURLToPath } from 'node:url' import { JSDOM } from 'jsdom' import { expect, it, onTestFinished, vi } from 'vitest' import type { DesktopBackendState } from '../src/backend-controller.ts' import type { DshDesktopStartupApi } from '../src/ipc.ts' import { resolveDesktopLocale } from '../src/locale.ts' +import { startupFailureDocument } from '../src/startup-document.ts' function startup(locale = 'en', status: Promise = Promise.resolve({ phase: 'starting' })) { const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) @@ -16,6 +18,10 @@ function startup(locale = 'en', status: Promise = Promise.r const unsubscribe = vi.fn(() => { listeners.clear() }) const retry = vi.fn(async () => {}) const openPlugins = vi.fn(async () => {}) + const disablePlugins = vi.fn(async () => {}) + const resetConfiguration = vi.fn(async () => {}) + const restart = vi.fn(async () => {}) + const close = vi.fn(async () => {}) const queried = Promise.withResolvers() const api: DshDesktopStartupApi = { protocolVersion: 1, @@ -26,6 +32,7 @@ function startup(locale = 'en', status: Promise = Promise.r subscribe: (listener) => { listeners.add(listener); return unsubscribe }, }, openPlugins, + disablePlugins, resetConfiguration, restart, close, } Object.defineProperty(dom.window, 'dshDesktop', { value: api }) runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) @@ -42,12 +49,13 @@ function startup(locale = 'en', status: Promise = Promise.r } const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) } const copy = (): string => [element('#title').textContent, element('#description').textContent, - ...['#error', '#actions'].filter(selector => !element(selector).hidden) + ...['#reset-advice', '#reinstall-advice', '#error', '#actions'].filter(selector => !element(selector).hidden) .flatMap(selector => selector === '#actions' - ? [button('#retry').textContent, button('#plugins').textContent] + ? [...document.querySelectorAll('#actions button')].filter(button => !button.hidden).map(button => button.textContent) : [element(selector).textContent]), ].join('\n') - return { dom, document, element, button, publish, copy, retry, openPlugins, unsubscribe, queried: queried.promise } + return { dom, document, element, button, publish, copy, retry, openPlugins, + disablePlugins, resetConfiguration, restart, close, unsubscribe, queried: queried.promise } } it('shows English loading and recovery actions without a Host document', async () => { @@ -60,22 +68,23 @@ it('shows English loading and recovery actions without a Host document', async ( expect(page.element('main').getAttribute('aria-busy')).toBe('true') expect(page.element('#spinner').hidden).toBe(false) expect(page.element('#actions').hidden).toBe(true) - expect(page.button('#retry').disabled).toBe(true) - expect(page.button('#plugins').disabled).toBe(true) + expect(page.button('#restart').disabled).toBe(true) + expect(page.button('#disable-plugins').disabled).toBe(true) page.publish({ phase: 'error', message: 'Plugin failed to load' }) expect(page.copy()).toMatchInlineSnapshot(` "DeepSeek Harness could not start - The application could not start. Retry or manage Desktop plugins to resolve the problem. + Choose a recovery action below. Disabling third-party plugins retains their files. + Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. + If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. Plugin failed to load - Retry startup - Manage plugins" + Close and restart + Disable all third-party plugins and retry + Reset Desktop and retry" `) expect(page.element('main').getAttribute('aria-busy')).toBe('false') expect(page.element('#spinner').hidden).toBe(true) - page.button('#plugins').click() - expect(page.openPlugins).toHaveBeenCalledOnce() - page.button('#retry').click() - expect(page.retry).toHaveBeenCalledOnce() + page.button('#restart').click() + expect(page.restart).toHaveBeenCalledOnce() expect(page.element('#actions').hidden).toBe(true) expect(page.element('#error').textContent).toBe('') expect(page.element('main').getAttribute('aria-busy')).toBe('true') @@ -92,10 +101,13 @@ it('shows Chinese loading and recovery copy', async () => { page.publish({ phase: 'error', message: '插件加载失败' }) expect(page.copy()).toMatchInlineSnapshot(` "DeepSeek Harness 无法启动 - 应用未能启动。你可以重试,或管理桌面插件以解决问题。 + 请选择下方的恢复操作。禁用第三方插件会保留插件文件。 + 重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 + 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 插件加载失败 - 重试启动 - 管理插件" + 关闭并重启 + 禁用全部第三方插件并重试 + 重置 Desktop 并重试" `) }) @@ -106,14 +118,11 @@ it('renders diagnostic markup as text and exposes failures from recovery actions page.publish({ phase: 'error', message: diagnostic }) expect(page.element('#error').textContent).toBe(diagnostic) expect(page.element('#error').childElementCount).toBe(0) - page.openPlugins.mockRejectedValueOnce(new page.dom.window.Error('Cannot open plugin manager')) - page.button('#plugins').click() - await expect.poll(() => page.element('#error').textContent).toBe('Cannot open plugin manager') - page.retry.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) - page.button('#retry').click() + page.restart.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) + page.button('#restart').click() await expect.poll(() => page.element('#error').textContent).toBe('Retry failed') - expect(page.button('#retry').disabled).toBe(false) - expect(page.button('#plugins').disabled).toBe(false) + expect(page.button('#restart').disabled).toBe(false) + expect(page.button('#disable-plugins').disabled).toBe(false) }) it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => { @@ -132,3 +141,34 @@ it('keeps subscribed state when initial status arrives late and detaches on page page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) expect(page.unsubscribe).toHaveBeenCalledOnce() }) + +it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => { + const page = startup(locale) + await expect.poll(() => page.button('#restart').disabled).toBe(true) + for (const recovery of ['plugins', 'configuration', 'reinstall', 'restart'] as const) { + page.publish({ phase: 'error', recovery, message: 'Failure details' }) + await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-${recovery}.txt`, import.meta.url))) + expect(page.button('#disable-plugins').hidden).toBe(false) + expect(page.button('#reset-configuration').hidden).toBe(false) + const action = recovery === 'plugins' ? '#disable-plugins' : recovery === 'configuration' + ? '#reset-configuration' : '#restart' + page.button(action).click() + expect(page.element('#actions').hidden).toBe(true) + } + expect(page.disablePlugins).toHaveBeenCalledOnce() + expect(page.resetConfiguration).toHaveBeenCalledOnce() + expect(page.close).not.toHaveBeenCalled() + expect(page.restart).toHaveBeenCalledTimes(2) +}) + +it('keeps emergency diagnostics inert without shell assets', () => { + const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '') + const dom = new JSDOM(html) + expect(dom.window.document.querySelector('script')).toBeNull() + expect(dom.window.document.querySelector('pre')?.textContent).toBe('') + expect(dom.window.document.querySelector('p')?.textContent).toContain('重新安装') + expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual([ + 'dsh-recovery://restart', 'dsh-recovery://plugins', 'dsh-recovery://reset', + ]) + dom.window.close() +}) From dcec949f33fa3715d0fe2b43d243ad51ebb52726 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 11:50:46 +0800 Subject: [PATCH 05/19] fix(python): allow workspace-only patches during runtime deploy --- ...-single-file-executable-sdk-runtime-distribution.i18n.yaml | 4 ++-- ...6-07-10-single-file-executable-sdk-runtime-distribution.md | 2 +- ...7-10-single-file-executable-sdk-runtime-distribution.zh.md | 2 +- python/sdk-runtime/README.i18n.yaml | 4 ++-- python/sdk-runtime/README.md | 2 ++ python/sdk-runtime/README.zh.md | 2 ++ scripts/build-exe-for-python-sdk.spec.ts | 3 +++ scripts/build-exe-for-python-sdk.ts | 2 ++ 8 files changed, 15 insertions(+), 6 deletions(-) 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 d03d20b03b..f7987d80d7 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: 7792e24a5869be6b7bae7787a6a481f3075ba740 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 8c55c142137f0ab24869bd57ffed3835b7b0516a +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 52ca2e13901d9469e2f9663936acb766a934e8f7 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 7792e24a58..8c55c14213 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 @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### 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.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`. All four deploy flags are grounded in measurement: `--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. +[`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. 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, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 857bfaec80..52ca2e1390 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 @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.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/` 并拷回运行时目录。该 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 的明确排除。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 0cb760ac38..a479d3b412 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: fb7478f305015e60dafd23861ab6fd91f10757f3 -README.zh.md: 8acd09e6e349190716bad06cf5d4264db06f6197 +README.md: ce733b8f293e974ec051e600c187e0741117ab46 +README.zh.md: 23a042006c38425a83347322fa63f0fafb319d0d diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index fb7478f305..ce733b8f29 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -31,6 +31,8 @@ External profile management uses `dsh plugin --profile ...`. That command ## Build and distribution +Production deployment permits unused workspace patches for packages outside the runtime closure; patches for included packages must still apply successfully. This exception is confined to the deploy command; repository installation still rejects unused patches. + 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 distribution and executable provenance, 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). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 8acd09e6e3..23a042006c 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -31,6 +31,8 @@ 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)。 diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index 0fe43528f2..b0c937a9eb 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -60,6 +60,9 @@ describe('Python runtime executable builder CLI', () => { expect(result.status).toBe(0) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) + const deploy = result.stdout.split('\n').find(line => line.includes(' --filter dsh-python-runtime-closure deploy')) + expect(deploy).toContain('--prod --config.allow-unused-patches=true') + expect(result.stdout.split('--config.allow-unused-patches=true')).toHaveLength(2) expect(result.stdout).not.toContain(resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs')) expect(result.stdout).toContain('"bin":"runtime-bootstrap.mjs"') expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs exec pkg`) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index f58a526e64..b615666c31 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -292,6 +292,8 @@ class SingleExeBuild { 'deploy', '--legacy', '--prod', + // Production deployment omits workspace tooling such as Electron's patched signer. + '--config.allow-unused-patches=true', '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', From d55b2b3dadfc67939170107a05e00b7f9952a338 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 14:05:44 +0800 Subject: [PATCH 06/19] fix(desktop): make startup recovery and package rebuilds retryable --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 28 ++++---- ...ectron-desktop-packaging-and-updates.zh.md | 28 ++++---- ...led-runtime-and-external-plugins.i18n.yaml | 4 +- ...op-bundled-runtime-and-external-plugins.md | 10 +-- ...bundled-runtime-and-external-plugins.zh.md | 10 +-- ...mmediate-window-and-direct-start.i18n.yaml | 4 +- ...sktop-immediate-window-and-direct-start.md | 12 ++-- ...op-immediate-window-and-direct-start.zh.md | 12 ++-- ...6-09-09-desktop-in-place-profile.i18n.yaml | 4 +- .../2026-09-09-desktop-in-place-profile.md | 2 + .../2026-09-09-desktop-in-place-profile.zh.md | 2 + apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 18 +++-- apps/desktop/README.zh.md | 18 +++-- apps/desktop/renderer/startup.js | 4 +- apps/desktop/scripts/prepare-dsh.ts | 9 ++- apps/desktop/scripts/smoke-windows.ps1 | 67 +++++++++++++++++++ apps/desktop/src/backend-controller.ts | 4 +- apps/desktop/src/ipc.ts | 7 +- apps/desktop/src/locale.ts | 8 --- apps/desktop/src/main.ts | 56 ++++++---------- apps/desktop/src/preload-app.ts | 3 - apps/desktop/src/profile-packages.ts | 21 ++---- apps/desktop/src/project-manager.ts | 47 +++++++------ apps/desktop/src/startup-document.ts | 9 +-- apps/desktop/src/startup-error.ts | 28 ++------ apps/desktop/tests/backend-controller.spec.ts | 8 +-- .../tests/expected/startup-en-plugins.txt | 8 --- ...nfiguration.txt => startup-en-profile.txt} | 0 .../tests/expected/startup-en-reinstall.txt | 8 --- .../tests/expected/startup-en-restart.txt | 3 - .../tests/expected/startup-zh-CN-plugins.txt | 8 --- ...guration.txt => startup-zh-CN-profile.txt} | 0 .../expected/startup-zh-CN-reinstall.txt | 8 --- .../tests/expected/startup-zh-CN-restart.txt | 3 - apps/desktop/tests/main-startup.spec.ts | 46 ++++++++++++- apps/desktop/tests/preload-app.spec.ts | 7 +- apps/desktop/tests/project-manager.spec.ts | 63 +++++++++++++++-- apps/desktop/tests/startup-renderer.spec.ts | 45 +++++++------ 40 files changed, 356 insertions(+), 274 deletions(-) create mode 100644 apps/desktop/scripts/smoke-windows.ps1 delete mode 100644 apps/desktop/tests/expected/startup-en-plugins.txt rename apps/desktop/tests/expected/{startup-en-configuration.txt => startup-en-profile.txt} (100%) delete mode 100644 apps/desktop/tests/expected/startup-en-reinstall.txt delete mode 100644 apps/desktop/tests/expected/startup-zh-CN-plugins.txt rename apps/desktop/tests/expected/{startup-zh-CN-configuration.txt => startup-zh-CN-profile.txt} (100%) delete mode 100644 apps/desktop/tests/expected/startup-zh-CN-reinstall.txt diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 494f94041f..3c31c033aa 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: 2e266b74c2c1a5de62312ddce2c43de0908b2675 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 0a48e80b47450db7e45ab96f3a7b6e465ca271b6 +2026-08-25-electron-desktop-packaging-and-updates.md: f92f15542b1903cdfe5c7b2794ce872becab3517 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: e67d00417fb4e80cff742862537572653ad8d22c diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 2e266b74c2..f92f15542b 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) -Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. +Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). ## Problem @@ -28,7 +28,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p | Owner | Responsibility | |---|---| -| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback | +| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination | | Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state | | Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision | | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication | @@ -43,10 +43,6 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ```text ~/.dsh/ desktop/ - staging//profile/ - rollback/profile/ - pending.json - lock pnpm/ store/ cache/ @@ -56,6 +52,8 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb desktop/ package.json pnpm-lock.yaml + lock + desktop-packages-pending pnpm-workspace.yaml desktop-runtime-state.json node_modules/ @@ -67,9 +65,9 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ## Installation and resolution -The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Activation stops the active backend, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. +Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md). -The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. +The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered. Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts. @@ -79,7 +77,7 @@ Electron update uses one `electron-updater` release stream and signed `electron- The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. ## Security and release policy @@ -109,10 +107,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr |---|---| | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. | | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. | -| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in Desktop staging. | +| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. | | Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | -`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, runtime preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. +`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, dsh preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. ## Alternatives considered @@ -146,7 +144,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr - The backend and browser application cannot mutate desktop packages. - npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`. - The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads. -- Failed installation, backend activation, or update leaves the current profile usable or restores `rollback/profile` after restart. +- Package or Host failures retain partial profile changes and expose recovery controls; no automatic profile rollback is promised. - One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart. - Shared `.dsh` data rejects incompatible readers before migration or mutation. - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. @@ -162,7 +160,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | First launch | Check bundled release metadata and create profile links without installing core dependencies | | Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | -| Activation | Staging project, journaled directory replacement, actual Host startup, one rollback copy | +| Activation | In-place package changes followed by actual Host startup | | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | | Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation | @@ -170,11 +168,11 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships. -Updating the bound dsh can invalidate plugin peer dependencies or native modules. Dependency validation rejects incompatible metadata before replacement; actual Host startup can fail after replacement and trigger profile rollback. +Updating the bound dsh can invalidate plugin peer dependencies or native modules. Reconciliation validates changed dependencies and rebuilds native packages; failures require explicit repair through the recovery UI. An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing. -Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move. +Interrupted package operations retain a pending marker. Installed-artifact tests must verify that a later launch retries the locked installation and approved native builds. Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 0a48e80b47..e67d00417f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 -本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 +profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 ## 问题 @@ -28,7 +28,7 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] | Owner | 职责 | |---|---| -| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 | +| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调 | | 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 | | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 | | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | @@ -43,10 +43,6 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] ```text ~/.dsh/ desktop/ - staging//profile/ - rollback/profile/ - pending.json - lock pnpm/ store/ cache/ @@ -56,6 +52,8 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] desktop/ package.json pnpm-lock.yaml + lock + desktop-packages-pending pnpm-workspace.yaml desktop-runtime-state.json node_modules/ @@ -67,9 +65,9 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] ## 安装与解析 -安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。激活过程停止活跃后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 +Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。 -进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 +进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。 核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。 @@ -79,7 +77,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b [立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。 -`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 +`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 ## 安全与发布策略 @@ -109,10 +107,10 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录 |---|---| | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 | -| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop staging 中的外部插件依赖图。 | +| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 | | 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | -`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、运行时准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 +`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 ## 考虑过的替代方案 @@ -146,7 +144,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录 - 后端与浏览器应用不能修改桌面包。 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 - 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 -- 安装、后端激活或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 +- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 @@ -162,7 +160,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录 | 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 | | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | -| 激活 | 暂存项目、记录式目录替换、实际 Host 启动和一个回滚副本 | +| 激活 | 直接修改包后启动实际 Host | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | @@ -170,11 +168,11 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录 插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。 -更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。依赖验证在替换前拒绝不兼容的元数据;实际 Host 启动可能在替换后失败,并触发 profile 回滚。 +更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。校准会验证变化的依赖并重建原生包;失败后需要通过恢复 UI 显式修复。 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 -不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。 +中断的包操作保留未完成标记。已安装产物测试必须验证后续启动会重试锁定依赖的安装和获准的原生构建。 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 5c992fd72d..bd9e5a5bf2 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 9fcee1d49d4d2595fa9dfc929b58ee3e5262ac54 -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: f448029cbf840e8003ce26d67788663168629da4 +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 581a4ad31121bfda651cb99e550487a0a712e943 +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: fbbb25a4a3446d52ac56dc35bb176c0b706d7cb4 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index 9fcee1d49d..581a4ad311 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) -Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. +Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). ## Problem @@ -34,15 +34,15 @@ The profile manifest records exact installed plugin dependencies separately from ## Transactions and upgrades -First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, and validates enabled peers. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. +First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links and validate enabled peers in place. Node version, platform, or architecture changes reinstall the locked plugin graph and run approved native builds. -Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary staging and backend replacement. Transaction cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. +Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction. -The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes. +Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and persistent retry state. Recorded host links identify owned directories independently of package-operation completion. -The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window, replacing staged backend probes. Users can open plugin management to update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Each backend launch requires the current runtime identity. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index f448029cbf..fbbb25a4a3 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 -本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 +profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 ## 问题 @@ -34,15 +34,15 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 事务与升级 -首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,并验证已启用的 peer。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 +首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接并验证已启用的 peer。Node 版本、平台或架构变化时,会重新安装锁定的插件依赖图并运行获准的原生构建。 -共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 staging 和后端替换。事务清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 +共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。 -事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。 +Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和持久重试状态的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立。 -[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host 和主窗口恢复,取代 staging 后端探针。用户可以打开插件管理,更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。每次后端启动都要求当前运行时标识。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml index e26d213c6e..aa2dec8161 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-desktop-immediate-window-and-direct-start.md -2026-09-09-desktop-immediate-window-and-direct-start.md: 7491e4cedf058bee4dc81b7734f54bc4caf6aa72 -2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 37ec49e737cd5f765ab97de8c534bb08b230a45d +2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md index 7491e4cedf..1c2209c044 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) -Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. +Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). ## Problem @@ -12,22 +12,22 @@ Waiting for backend readiness leaves users without a window during profile prepa ## Decision -Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit. +Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit. -The main window owns recovery because the failed Host cannot supply its own controls. Every error page retains diagnostics and exposes restart, disable-third-party-plugins, and reset-Desktop actions, plus reinstallation guidance. The actions remain available regardless of error classification. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The lock lives inside the profile, and reset preserves its directory so another transaction cannot acquire a replacement lock during cleanup. Disabled third-party package files cannot block startup when no third-party bundles are enabled. Self-contained shell recovery controls use intercepted form navigation so a failed preload cannot disable them. +The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available only in a packaged application with loaded runtime metadata and available resources. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again. -Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes. +Desktop starts the actual Host after preparing the profile in place, without booting a separate health-check backend. Package mutations retain dependency validation, approved lifecycle builds, runtime identity checks, and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback. This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. ## Alternatives considered -**Keep a complete staged health check.** It can reject a startup failure before replacing the active profile, but executes plugin initialization twice and cannot guarantee that the subsequent serving process will start. The actual startup result and journaled rollback provide recovery without the extra probe. +**Keep a complete staged health check.** It can reject some startup failures before activation, but executes plugin initialization twice and cannot guarantee that the serving process will start. The actual Host result provides the diagnostic needed for explicit recovery. **Keep the main window hidden until readiness.** This avoids presenting a loading page but gives users no visible progress or interaction while the backend loads. A shell-owned page can remain available when Host startup fails. ## Consequences -Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Activation can fail after profile replacement; the journal retains recovery for profile files. +Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails. Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md index 37ec49e737..49b08e4eee 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 -本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 +profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 ## 问题 @@ -12,22 +12,22 @@ Status: implemented ## 决策 -Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断和恢复操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 +Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。 -主窗口负责恢复,因为故障 Host 无法提供自己的操作界面。每个错误页保留诊断,提供重启、禁用第三方插件和重置 Desktop 三个操作,以及重装提示。所有操作均不依赖错误分类而保持可用。重置删除 profile 内除当前持有的锁之外的全部内容,不留备份;共享产品数据和 Harness home 环境文件保持不变。锁位于 profile 内,重置保留目录,防止其他事务在清理期间取得替代锁。没有启用第三方 bundle 时,已禁用插件的文件不能阻止启动。独立内嵌错误页通过拦截表单导航执行恢复,因此 preload 故障不会使按钮失效。 +主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。只有已打包应用加载了运行时元数据且资源可用时,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。 -profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。 +Desktop 直接准备 profile 后启动实际 Host,不另行启动健康检查后端。包修改保留依赖验证、获准生命周期构建、运行时标识检查和锁。失败后保留部分修改,供显式修复;不自动回滚 profile。 本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。 ## 考虑过的替代方案 -**保留完整 staging 健康检查。** 它可以在替换活动 profile 前拒绝启动失败,但会执行两次插件初始化,也不能保证后续服务进程能够启动。实际启动结果与记录式回滚无需额外探针即可提供恢复能力。 +**保留完整 staging 健康检查。** 它可以在激活前拒绝部分启动失败,但会执行两次插件初始化,也不能保证服务进程能够启动。实际 Host 结果提供显式恢复所需的诊断。 **在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。 ## 后果 -产品 UI 可用前,用户就能看到启动进度并从失败中恢复。窗口可响应并不表示后端已就绪,启动延迟仍需测量安装产物。激活可能在替换 profile 后失败;事务日志保留 profile 文件的恢复能力。 +用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。 验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml index 25f423496f..071c6eb3c0 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.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-09-desktop-in-place-profile.md -2026-09-09-desktop-in-place-profile.md: c83bb9826a4068f699e330f911c52064790b08b6 -2026-09-09-desktop-in-place-profile.zh.md: 481dcc11d6c4dcc033f68186c88bfd96028fdbe8 +2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385 +2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md index c83bb9826a..9a221bb334 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md @@ -16,6 +16,8 @@ Package or Host failures retain partial changes for repair and retry. There is n This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active. +A persistent `desktop-packages-pending` marker precedes package writes or native-runtime rebuilding and is removed only after installation, approved builds, and validation succeed. A later launch with that marker reinstalls the locked graph and retries pending builds even when recorded runtime metadata already matches. Ordinary unchanged startups reuse the profile without scanning the plugin dependency graph; package mutations and runtime reconciliation retain validation. + ## Alternatives considered Staging protects the previous installation at the cost of copying and crash recovery. Versioned directories still need preparation, selection, and cleanup. Direct writes give up automatic recovery; reintroduction requires an unattended-recovery product requirement that justifies these costs. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md index 481dcc11d6..53a744ae9e 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md @@ -16,6 +16,8 @@ Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共 本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。 +持久的 `desktop-packages-pending` 标记先于包写入或原生运行时重建,仅在安装、获准构建和验证成功后删除。后续启动发现该标记时,会重新安装锁定的依赖图并重试待执行构建,即使记录的运行时元数据已经匹配。普通未变化的启动复用 profile,不扫描插件依赖图;包修改和运行时校准保留验证。 + ## 考虑过的替代方案 staging 以复制和崩溃恢复为代价保护旧安装。版本化目录仍需要准备、选择和清理。直接写入放弃自动恢复;只有无人值守恢复的产品要求能证明这些成本合理时,才重新引入。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 585a75b020..83d3b39d4e 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: 91cdd6d4ea0e001dd9ae81dc9f8e64aa5a912879 -README.zh.md: 6bfddb6fec4a8b525991a9f3cc4eb7f1a431d6b8 +README.md: 62d592307161f202f156d66d91f5a4663c3facc1 +README.zh.md: ace1497876d8b60612b8b5adf1d3ee86f1beeaa4 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 91cdd6d4ea..62d5923071 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -23,7 +23,7 @@ The [Electron packaging and update Agent Note](../../.agents/notes/implemented/a Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The local startup page exposes startup status, retry, and plugin-management actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The local startup page exposes startup status and available recovery actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. @@ -31,15 +31,15 @@ Electron chooses typed English or Chinese shell copy from its application locale The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. -1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once. +1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests. 2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run. 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. -5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error; repair the installation and retry. Desktop creates no staging directories, activation journals, or rollback copies. +5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Unfinished package operations retain a marker so the next launch retries the locked installation and pending builds. Desktop creates no staging directories, activation journals, or rollback copies. -The loading page does not depend on the Host. Every startup and backend error page retains diagnostics and offers three actions: restart the application, disable all third-party plugins and restart the Host, or reset Desktop and restart the Host. Reinstallation guidance is always visible for missing or damaged application files. Disabling plugins retains their files and skips their installation graph when none are enabled. The plugin manager remains available through the application menu. Runtime compatibility is checked before any backend starts; plugin changes have no automatic rollback. +The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered only when packaged application resources support profile recovery; development and early initialization failures expose restart alone. The plugin manager remains available through the application menu. Runtime identity is checked before any backend starts; plugin changes have no automatic rollback. -Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the same three actions and diagnostics; its controls do not require preload. +Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload. Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. @@ -96,7 +96,13 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t 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/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 packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) exercises the resulting native modules and HTML converter under the bundled Node. +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 the bundled Node before the Host smoke and final inventory verification. + +Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, installer scratch cleanup, and both locked-file replacement modes; it is not part of the unit-test lane. + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir +``` ### Upload updates diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 6bfddb6fec..ace1497876 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -23,7 +23,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -本地启动页提供启动状态、重试和插件管理操作;加载后的 dsh 渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 +本地启动页提供启动状态和可用恢复操作;加载后的 dsh 渲染进程仅接收桌面协议标记。独立插件窗口接收结构化的列表、安装、删除、更新和更新检查操作;两个渲染进程都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 @@ -31,15 +31,15 @@ Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案, 签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 -1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest(元数据清单)和共享包链接,然后激活并启动一次实际后端。 +1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单。 2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 -5. 插件变更在修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败保留已修改文件并报告错误;修复安装后重试。Desktop 不创建 staging 目录、激活日志或回滚副本。 +5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。未完成的包操作保留标记,使下次启动重试锁定依赖的安装和待执行构建。Desktop 不创建 staging 目录、激活日志或回滚副本。 -加载页不依赖 Host。每个启动和后端错误页均保留诊断,并提供三个操作:重启应用、禁用全部第三方插件并重启 Host、重置 Desktop 并重启 Host。页面始终显示应用文件缺失或损坏时的重装提示。禁用插件会保留文件;没有启用的第三方插件时,不再检查它们的安装依赖图。插件管理仍可从应用菜单打开。每次启动后端前都会检查运行时兼容性;插件变更不自动回滚。 +加载页不依赖 Host。错误页提供重启和重装指导。只有已打包应用的资源支持 profile 恢复时,才提供禁用插件和重置 Desktop;开发模式和早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。每次后端启动前都会检查运行时标识;插件修改不自动回滚。 -重置会删除 `$DSH_HOME/profiles/desktop` 内除当前持有的事务锁之外的全部内容,再初始化内置 profile。Desktop 配置和已安装第三方插件会被删除,不保留备份。共享任务、设置和 Harness home 的 `.env` 不受影响。壳资源或 preload 故障使用独立内嵌页面,保留相同的三个操作和诊断;其按钮不依赖 preload。 +重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload。 包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 @@ -96,7 +96,13 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。 -打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)使用内置 Node 验证过滤后的原生模块和 HTML 转换器。 +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用内置 Node 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。 + +Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、安装器临时目录清理和两种文件占用替换方式;不属于单元测试通道。 + +```powershell +pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir +``` ### 上传更新 diff --git a/apps/desktop/renderer/startup.js b/apps/desktop/renderer/startup.js index 036b950b0a..5146bf6f5e 100644 --- a/apps/desktop/renderer/startup.js +++ b/apps/desktop/renderer/startup.js @@ -19,7 +19,9 @@ async function main() { document.querySelector('#error').textContent = failed ? state.message : '' document.querySelector('#actions').hidden = !failed for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed - document.querySelector('#reset-advice').hidden = !failed + for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) { + document.querySelector(selector).hidden = !failed || !state.profileRecovery + } document.querySelector('#reinstall-advice').hidden = !failed } let changed = false diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index eb223fbc7e..d3f9dae8a3 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -1,6 +1,6 @@ /** Materialize the complete production runtime before publishing Desktop resources. */ -import { spawn } from 'node:child_process' +import { spawn, execFile } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join, relative, resolve } from 'node:path' @@ -138,6 +138,13 @@ async function main(): Promise { } writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + await new Promise((accept, reject) => { + execFile(NODE, [join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT], + { timeout: 120_000, env: { ...process.env, NODE_OPTIONS: '' } }, (error, stdout, stderr) => { + if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error })) + else { process.stdout.write(stdout); accept() } + }) + }) await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) } catch (error) { diff --git a/apps/desktop/scripts/smoke-windows.ps1 b/apps/desktop/scripts/smoke-windows.ps1 new file mode 100644 index 0000000000..9afdc6c45c --- /dev/null +++ b/apps/desktop/scripts/smoke-windows.ps1 @@ -0,0 +1,67 @@ +# Run native Electron cleanup and NSIS replacement checks against the prepared Windows target. +param( + [Parameter(Mandatory)][string]$Electron, + [Parameter(Mandatory)][string]$Makensis, + [Parameter(Mandatory)][string]$SevenZip, + [Parameter(Mandatory)][string]$PluginDir +) +$ErrorActionPreference = 'Stop' +$desktopRoot = Split-Path $PSScriptRoot -Parent +$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName +$fixtureRoot = Join-Path $desktopRoot 'tests/fixtures' + +function Invoke-Checked([string]$Executable, [string[]]$Arguments) { + & $Executable @Arguments | Out-Host + if ($LASTEXITCODE -ne 0) { throw "$Executable exited with $LASTEXITCODE" } +} + +try { + $previousRunAsNode = $env:ELECTRON_RUN_AS_NODE + try { + $env:ELECTRON_RUN_AS_NODE = '1' + Invoke-Checked $electron @((Join-Path $fixtureRoot 'owned-directory-smoke.mjs')) + } finally { $env:ELECTRON_RUN_AS_NODE = $previousRunAsNode } + + $cleanupExe = Join-Path $scratch 'cleanup.exe' + $cleanupResult = Join-Path $scratch 'cleanup.txt' + Invoke-Checked $Makensis @('/V2', "/DOUTPUT_FILE=$cleanupExe", "/DRESULT_FILE=$cleanupResult", (Join-Path $fixtureRoot 'installer-cleanup-smoke.nsi')) + Invoke-Checked $cleanupExe @('/S') + if ((Get-Content -LiteralPath $cleanupResult -Raw) -ne 'scratch removed; archive, plugin, rollback, registers and error flags preserved') { + throw 'NSIS cleanup did not preserve its sentinels' + } + + $payload = Join-Path $scratch 'payload' + New-Item -ItemType Directory -Path $payload | Out-Null + [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime') + [System.IO.File]::WriteAllText((Join-Path $payload 'asset.txt'), 'new asset') + $archive = Join-Path $scratch 'payload.7z' + Invoke-Checked $SevenZip @('a', '-bd', '-t7z', $archive, "$payload/*") + foreach ($mode in @('copy', 'direct')) { + $target = Join-Path $scratch $mode + New-Item -ItemType Directory -Path $target | Out-Null + $locked = Join-Path $target 'locked.txt' + [System.IO.File]::WriteAllText($locked, 'old runtime') + $probeExe = Join-Path $scratch "$mode.exe" + $probeResult = Join-Path $scratch "$mode.txt" + $compileArgs = @('/V2', "/DOUTPUT_FILE=$probeExe", "/DRESULT_FILE=$probeResult", "/DPAYLOAD_FILE=$archive", "/DTARGET_DIR=$target", "/DPLUGIN_DIR=$PluginDir") + if ($mode -eq 'direct') { $compileArgs += '/DDIRECT' } + $compileArgs += Join-Path $fixtureRoot 'installer-write-failure-smoke.nsi' + Invoke-Checked $Makensis $compileArgs + $handle = [System.IO.File]::Open($locked, 'Open', 'Read', 'Read') + try { Invoke-Checked $probeExe @('/S') } + finally { $handle.Dispose() } + $flag = if ($mode -eq 'copy') { 'true' } else { 'false' } + $expected = "errorFlag=$flag`nlocked=old runtime`nasset=new asset" + if ((Get-Content -LiteralPath $probeResult -Raw).Replace("`r`n", "`n").TrimEnd() -ne $expected) { + throw "Unexpected NSIS $mode replacement result" + } + } + Write-Output 'Electron cleanup and NSIS cleanup/replacement smokes passed.' +} finally { + $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + $resolvedScratch = [System.IO.Path]::GetFullPath($scratch) + if (-not $resolvedScratch.StartsWith($tempRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing cleanup outside the temporary root: $resolvedScratch" + } + Remove-Item -LiteralPath $resolvedScratch -Recurse -Force +} diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts index 91850ef926..648de03975 100644 --- a/apps/desktop/src/backend-controller.ts +++ b/apps/desktop/src/backend-controller.ts @@ -1,9 +1,9 @@ /** Owns one backend startup and its quiescent teardown independently of windows. */ -import { desktopErrorState, type DesktopRecovery } from './startup-error.ts' +import { desktopErrorState } from './startup-error.ts' /** Backend availability presented by the desktop window. */ -export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly recovery?: DesktopRecovery } +export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean } /** Child lifecycle owned by the desktop backend controller. */ export interface DesktopBackendHost { diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 88735a2ad4..6883c978ac 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -8,7 +8,6 @@ import type { DesktopBackendState } from './backend-controller.ts' export const DESKTOP_IPC = { localeGet: 'dsh-desktop:locale-get', pluginsList: 'dsh-desktop:plugins-list', - pluginsOpen: 'dsh-desktop:plugins-open', pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', pluginsUpdate: 'dsh-desktop:plugins-update', @@ -17,7 +16,6 @@ export const DESKTOP_IPC = { backendStatus: 'dsh-desktop:backend-status', backendRetry: 'dsh-desktop:backend-retry', applicationRestart: 'dsh-desktop:application-restart', - applicationClose: 'dsh-desktop:application-close', configurationReset: 'dsh-desktop:configuration-reset', backendState: 'dsh-desktop:backend-state', updatesCheck: 'dsh-desktop:updates-check', @@ -57,10 +55,9 @@ export interface DshDesktopApi { } /** Startup-page controls, unavailable to backend-provided application documents. */ -export interface DshDesktopStartupApi extends Pick { - openPlugins(): Promise +export interface DshDesktopStartupApi extends Pick { + readonly backend: Omit disablePlugins(): Promise restart(): Promise - close(): Promise resetConfiguration(): Promise } diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 142d263d3c..e2e4d9e6c3 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -6,15 +6,11 @@ export const en = { startupLoading: 'Starting DeepSeek Harness…', startupLoadingDescription: 'Your workspace will open when it is ready.', startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.', - startupRestartAdvice: 'Close and restart the application. If the problem continues, keep the error details for troubleshooting.', startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', - startupPluginsAdvice: 'A third-party plugin may be preventing startup. Disable all third-party plugins and try again. Plugin files and tasks are retained.', startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.', restartApplication: 'Close and restart', - closeApplication: 'Close application', resetConfiguration: 'Reset Desktop and retry', disableThirdPartyPlugins: 'Disable all third-party plugins and retry', - managePlugins: 'Manage plugins', pluginsMenu: 'Desktop Plugins…', pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', checkUpdatesMenu: 'Check for Updates…', @@ -64,15 +60,11 @@ export const zh = { startupLoading: '正在启动 DeepSeek Harness…', startupLoadingDescription: '准备就绪后将自动打开工作区。', startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。', - startupRestartAdvice: '请关闭并重启应用。如果问题持续出现,请保留错误详情以便排查。', startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', - startupPluginsAdvice: '第三方插件可能导致应用无法启动。请禁用全部第三方插件后重试。插件文件和任务数据会保留。', startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。', restartApplication: '关闭并重启', - closeApplication: '关闭应用', resetConfiguration: '重置 Desktop 并重试', disableThirdPartyPlugins: '禁用全部第三方插件并重试', - managePlugins: '管理插件', pluginsMenu: '桌面插件…', pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', checkUpdatesMenu: '检查更新…', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 012f444e03..a73a6223a5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,12 +20,13 @@ import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' -import { desktopErrorState, DesktopStartupError } from './startup-error.ts' +import { desktopErrorState } from './startup-error.ts' import { startupFailureDocument } from './startup-document.ts' const SCHEME = 'dsh-app' let focusPrimaryWindow = (): void => {} type RecoveryAction = 'restart' | 'plugins' | 'reset' +let profileRecoveryAvailable = (): boolean => false const emergencyPages = new WeakMap() let recoverApplication = (action: RecoveryAction): Promise => { if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application')) @@ -35,16 +36,12 @@ let recoverApplication = (action: RecoveryAction): Promise => { } async function showEmergencyDocument(window: BrowserWindow, message: string): Promise { - const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message) + const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable()) const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}` emergencyPages.set(window, { url, message, busy: false }) await window.loadURL(url) } -function errorOf(reason: unknown, fallback: string): Error { - return reason instanceof Error ? reason : new Error(fallback) -} - protocol.registerSchemesAsPrivileged([{ scheme: SCHEME, privileges: { @@ -112,6 +109,7 @@ function createWindow(preload: string, show = false): BrowserWindow { if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return const action = new URL(url) if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return + if (action.hostname !== 'restart' && !profileRecoveryAvailable()) return page.busy = true void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => { if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`) @@ -155,6 +153,7 @@ async function main(): Promise { const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project') const activeProject = development ?? paths.profile const manager = new DesktopProjectManager(paths, resources) + profileRecoveryAvailable = () => development === undefined && manager.canRecoverProfile() let pageError: Extract | undefined let quitting = false let startup: Promise | undefined @@ -175,7 +174,7 @@ async function main(): Promise { if (quitting || emergencyDocument) return emergencyDocument = true const diagnostic = desktopErrorState(error).message - pageError = { phase: 'error', message: diagnostic, recovery: 'reinstall' } + pageError = { phase: 'error', message: diagnostic } if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic) } @@ -192,8 +191,10 @@ async function main(): Promise { navigation = next return next.promise } - const backendState = (): DesktopBackendState => pageError === undefined - ? backend.state : pageError + const backendState = (): DesktopBackendState => { + const state = pageError ?? backend.state + return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state + } const publishBackend = (state: DesktopBackendState): void => { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send(DESKTOP_IPC.backendState, state) @@ -202,26 +203,10 @@ async function main(): Promise { const backend = new DesktopBackendController((onFailure) => { if (development === undefined) manager.assertProfileRuntime(activeProject) const hostInspectPort = developmentHostInspectPort(development !== undefined) - const pluginsEnabled = development === undefined && manager.hasEnabledPlugins() - const classify = (error: Error): Error => { - if (error instanceof DesktopStartupError) return error - if ((error.message.includes(resources.node) || error.message.includes(resources.dsh)) - && /ENOENT|Cannot find|failed to (?:read|parse) overlay/u.test(error.message)) { - return new DesktopStartupError('reinstall', error) - } - if (error.message.includes('only the launching environment may set') - || /failed to (?:read|parse) (?:overlay|patches)|must be a top-level YAML array|patchReload must be/u.test(error.message)) { - return new DesktopStartupError('configuration', error) - } - return pluginsEnabled ? new DesktopStartupError('plugins', error) : error - } - const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, - (error) => { onFailure(classify(error)) }) + const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, + hostInspectPort, process.env, onFailure) return { - async start() { - try { return await host.start() } - catch (error) { throw classify(errorOf(error, messages.startupFailed)) } - }, + start: () => host.start(), stop: () => host.stop(), fetch: (request: Request) => host.fetch(request), } @@ -252,7 +237,7 @@ async function main(): Promise { app.quit() return } - if (development !== undefined) throw new Error('Desktop profile recovery requires a packaged application') + if (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice) if (action === 'reset') await manager.resetConfiguration(hooks) else await manager.mutate({ type: 'plugins-disable-all' }, hooks) emergencyDocument = false @@ -363,10 +348,6 @@ async function main(): Promise { await reconcileBackend() focusPrimaryWindow() }) - ipcMain.handle(DESKTOP_IPC.applicationClose, (event) => { - assertDesktopSender(event, ['shell']) - app.quit() - }) ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => { assertDesktopSender(event, ['shell']) try { @@ -452,10 +433,6 @@ async function main(): Promise { pluginWindow.once('closed', () => { pluginWindow = undefined }) void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) } - ipcMain.handle(DESKTOP_IPC.pluginsOpen, (event) => { - assertDesktopSender(event, ['shell']) - openPluginWindow() - }) Menu.setApplicationMenu(Menu.buildFromTemplate([{ label: process.platform === 'darwin' ? app.name : messages.application, @@ -480,6 +457,8 @@ async function main(): Promise { void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) }) }) window.webContents.on('render-process-gone', (_event, details) => { + navigation = undefined + emergencyDocument = false void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`)) .catch((failure: unknown) => { console.error(failure) }) }) @@ -536,4 +515,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true) window.once('closed', () => { app.quit() }) await showEmergencyDocument(window, message) +}).catch((error: unknown) => { + console.error(error) + app.exit(1) }) diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 65c55ccf11..c05b749a05 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -9,17 +9,14 @@ const startup: DshDesktopStartupApi = { locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType, backend: { status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, - retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, subscribe(listener) { const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } ipcRenderer.on(DESKTOP_IPC.backendState, handle) return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } }, }, - openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise, disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise, - close: () => ipcRenderer.invoke(DESKTOP_IPC.applicationClose) as Promise, resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise, } diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts index 18aa4ffaf6..1cb907ffa7 100644 --- a/apps/desktop/src/profile-packages.ts +++ b/apps/desktop/src/profile-packages.ts @@ -5,7 +5,6 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathS import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { satisfies } from 'semver' -import { DesktopStartupError } from './startup-error.ts' import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts' /** Applied runtime identity and the only links Desktop may replace. */ @@ -17,7 +16,7 @@ export interface DesktopPackageLink { readonly target: string } -/** Facts from the last successfully validated profile. */ +/** Runtime identity and managed links; package preparation may still be pending. */ export interface DesktopProfileState { readonly schemaVersion: 1 readonly runtimeId: string @@ -53,11 +52,6 @@ function inside(root: string, path: string): boolean { * @returns Validated state, or undefined for an uninitialized profile. */ export function readDesktopProfileState(profile: string): DesktopProfileState | undefined { - try { return readProfileState(profile) } - catch (error) { throw new DesktopStartupError('configuration', error) } -} - -function readProfileState(profile: string): DesktopProfileState | undefined { const path = join(profile, DESKTOP_PROFILE_STATE) if (!existsSync(path)) return undefined const value: unknown = JSON.parse(readFileSync(path, 'utf8')) @@ -179,8 +173,7 @@ export function validateDesktopPluginGraph( ): void { const profileRoot = realpathSync.native(profile) const shared = new Map(runtime.sharedPackages.map((entry) => { - try { return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const } - catch (error) { throw new DesktopStartupError('reinstall', error) } + return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const })) for (const [name, path] of shared) { if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) @@ -238,12 +231,8 @@ export function validateDesktopPluginGraph( } } for (const name of activePlugins) { - try { - const path = packageFrom(profile, name) - if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) - visit(path, name) - } catch (error) { - throw new DesktopStartupError('plugins', error) - } + const path = packageFrom(profile, name) + if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) + visit(path, name) } } diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index d87623dcba..195d45efda 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -26,7 +26,6 @@ import { import type { DesktopPaths } from './paths.ts' import { removeOwnedDirectory } from './owned-directory.ts' import type { DesktopRelease } from './release.ts' -import { DesktopStartupError } from './startup-error.ts' import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' import { desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, @@ -123,11 +122,11 @@ function assertVersion(version: string): void { } /** - * Validate one registry package spec and return its requested package name when explicit. + * Validate one registry package spec and return its package name. * @param spec - npm registry name with an optional version or tag. - * @returns package name, or undefined when the spec's final name is registry-resolved. + * @returns Requested package name. */ -export function packageNameFromSpec(spec: string): string | undefined { +export function packageNameFromSpec(spec: string): string { if (spec === '' || spec.startsWith('-') || /[\s\\]/u.test(spec) || spec.includes('://') || spec.startsWith('file:')) { throw new Error(`desktop project: unsupported npm package spec ${JSON.stringify(spec)}`) } @@ -148,11 +147,6 @@ export function packageNameFromSpec(spec: string): string | undefined { } function projectManifest(projectDir: string): DesktopProjectManifest { - try { return readProjectManifest(projectDir) } - catch (error) { throw new DesktopStartupError('configuration', error) } -} - -function readProjectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined @@ -173,11 +167,11 @@ function readProjectManifest(projectDir: string): DesktopProjectManifest { function profilePluginNames(projectDir: string): readonly string[] { const bundles = projectManifest(projectDir).dsh.profile.bundles if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) { - throw new DesktopStartupError('configuration', new Error('desktop project: profile must begin with the built-in desktop bundle list')) + throw new Error('desktop project: profile must begin with the built-in desktop bundle list') } const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length) if (new Set(bundles).size !== bundles.length) { - throw new DesktopStartupError('configuration', new Error('desktop project: profile bundle list contains a duplicate package')) + throw new Error('desktop project: profile bundle list contains a duplicate package') } for (const plugin of plugins) assertPackageName(plugin) return plugins @@ -284,19 +278,27 @@ export class DesktopProjectManager { /** Reject a profile whose dependency links were prepared for another runtime. */ assertProfileRuntime(projectDir: string): void { + if (existsSync(this.pendingPackages)) throw new Error('desktop project: package preparation is incomplete; retry startup') if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) { throw new Error('desktop project: profile does not match this application runtime') } } + /** @returns Whether application resources support profile recovery. */ + canRecoverProfile(): boolean { + return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh) + } + + private get pendingPackages(): string { return join(this.paths.profile, 'desktop-packages-pending') } + private currentRuntime(): DesktopRuntimeDescriptor { if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') return this.descriptor } private readRuntime(): DesktopRuntimeDescriptor { - try { return readDesktopRuntime(this.runtime.dsh) } - catch (error) { throw new DesktopStartupError('reinstall', error) } + this.descriptor = undefined + return readDesktopRuntime(this.runtime.dsh) } private prepareProfile(projectDir: string): void { @@ -311,16 +313,12 @@ export class DesktopProjectManager { const target = this.readRuntime() this.descriptor = target const previous = readDesktopProfileState(this.paths.profile) - if (previous === undefined && readdirSync(this.paths.profile).some(name => join(this.paths.profile, name) !== this.paths.lock)) { - throw new DesktopStartupError('configuration', new Error('desktop project: existing profile is not a Desktop plugin profile')) - } - if (previous?.runtimeId === desktopRuntimeId(target) + if (!existsSync(this.pendingPackages) && previous?.runtimeId === desktopRuntimeId(target) && previous.lockHash === desktopPluginLockHash(this.paths.profile) && previous.links.length === target.sharedPackages.length && previous.links.every(link => existsSync(link.target) && existsSync(join(this.paths.profile, 'node_modules', link.name)) && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) { - validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } if (previous === undefined) createPluginProfile(this.paths.profile) @@ -360,28 +358,30 @@ export class DesktopProjectManager { private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise { const target = this.currentRuntime() - this.prepareProfile(projectDir) - const rebuild = previous !== undefined && pluginRecords(projectDir).length > 0 - && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch) + const rebuild = (!packagesChanged && existsSync(this.pendingPackages)) + || (previous !== undefined && pluginRecords(projectDir).length > 0 + && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch)) if (rebuild) { + writeFileSync(this.pendingPackages, '') unlinkDesktopHostPackages(projectDir) removeOwnedDirectory(join(projectDir, 'node_modules')) await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts']) } if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir) + else this.prepareProfile(projectDir) } private async finishPackageOperation(projectDir: string): Promise { this.prepareProfile(projectDir) await this.runPnpm(projectDir, ['rebuild', '--pending']) this.prepareProfile(projectDir) + unlinkSync(this.pendingPackages) } private async applyMutation(projectDir: string, mutation: Exclude): Promise { switch (mutation.type) { case 'plugin-add': { const requestedName = packageNameFromSpec(mutation.spec) - if (requestedName === undefined) throw new Error('desktop project: plugin package name is required') if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) { throw new Error(`desktop project: cannot install host-owned package ${requestedName}`) } @@ -445,6 +445,7 @@ export class DesktopProjectManager { const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))) + writeFileSync(this.pendingPackages, '') await new Promise((settle, reject) => { const child = spawn(this.runtime.node, [ this.runtime.pnpm, @@ -581,7 +582,6 @@ export function createRuntimeProjectMetadata(projectDir: string, release: Deskto workspaceFile(desktopCorePackageOverrides(packageSet)), { mode: 0o600 }, ) - writeJson(join(projectDir, 'desktop-release.json'), release) } /** @@ -603,7 +603,6 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De } writeJson(join(projectDir, 'package.json'), manifest) writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) - writeJson(join(projectDir, 'desktop-release.json'), release) } /** Create the first external plugin profile without running a package manager. */ diff --git a/apps/desktop/src/startup-document.ts b/apps/desktop/src/startup-document.ts index 65245649e2..70053a8a5f 100644 --- a/apps/desktop/src/startup-document.ts +++ b/apps/desktop/src/startup-document.ts @@ -6,9 +6,10 @@ import type { DesktopLocale } from './locale.ts' * Render escaped diagnostics without depending on application resource files. * @param locale - Shell-owned translations. * @param message - Failure details displayed as plain text. + * @param profileRecovery - Whether the initialized application can repair its profile. * @returns An HTML document suitable for an isolated emergency window. */ -export function startupFailureDocument(locale: DesktopLocale, message: string): string { +export function startupFailureDocument(locale: DesktopLocale, message: string, profileRecovery = false): string { const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<') .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') return ` @@ -16,10 +17,10 @@ export function startupFailureDocument(locale: DesktopLocale, message: string): ${escape(locale.messages.startupFailed)}

${escape(locale.messages.startupFailed)}

${escape(locale.messages.startupReinstallAdvice)}

-

${escape(locale.messages.startupConfigurationAdvice)}

+${profileRecovery ? `

${escape(locale.messages.startupConfigurationAdvice)}

` : ''}
${escape(message)}
-
-
+${profileRecovery ? `
+
` : ''}
` } diff --git a/apps/desktop/src/startup-error.ts b/apps/desktop/src/startup-error.ts index feb19c0dda..9c7c4c1d08 100644 --- a/apps/desktop/src/startup-error.ts +++ b/apps/desktop/src/startup-error.ts @@ -1,31 +1,13 @@ -/** Recovery guidance attached by the owner of a failed Desktop startup operation. */ - -/** Actions that can restore an unavailable Desktop backend. */ -export type DesktopRecovery = 'restart' | 'reinstall' | 'plugins' | 'configuration' - -/** An operation failure with recovery guidance independent of its diagnostic wording. */ -export class DesktopStartupError extends Error { - /** - * @param recovery - Recovery supported by the failed operation. - * @param cause - Original diagnostic, retained for troubleshooting. - */ - constructor(readonly recovery: DesktopRecovery, cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause), { cause }) - } -} +/** Serializable Desktop failure diagnostics. */ /** - * Preserve diagnostics and recovery guidance when sending failures to a renderer. + * Preserve nested diagnostics when sending failures to a renderer. * @param error - Startup or runtime failure. - * @returns Serializable error state; unclassified failures suggest restarting. + * @returns Serializable error state. */ -export function desktopErrorState(error: unknown): { - phase: 'error' - message: string - recovery: DesktopRecovery -} { +export function desktopErrorState(error: unknown): { phase: 'error'; message: string } { const message = error instanceof AggregateError ? [error.message, ...error.errors.map(item => desktopErrorState(item).message)].join('\n') : error instanceof Error ? error.message : String(error) - return { phase: 'error', message, recovery: error instanceof DesktopStartupError ? error.recovery : 'restart' } + return { phase: 'error', message } } diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts index c955ddc1e3..abdc78a9e7 100644 --- a/apps/desktop/tests/backend-controller.spec.ts +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -83,7 +83,7 @@ describe('desktop backend controller', () => { it('publishes preparation errors and permits retry', async () => { const f = fixture() await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') - expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile', recovery: 'restart' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) expect(f.create).not.toHaveBeenCalled() f.ready.resolve() await f.controller.start(async () => {}) @@ -101,7 +101,7 @@ describe('desktop backend controller', () => { await f.stopping.promise f.exited.resolve() await rejected - expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed', recovery: 'restart' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) expect(f.host.stop).toHaveBeenCalledTimes(1) await f.controller.close() }) @@ -111,7 +111,7 @@ describe('desktop backend controller', () => { f.ready.resolve() await f.controller.start(async () => {}) f.fail(new Error('transport failed')) - expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed', recovery: 'restart' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) expect(f.controller.host).toBeUndefined() await f.stopping.promise const prepare = vi.fn(async () => {}) @@ -135,7 +135,7 @@ describe('desktop backend controller', () => { await f.stopping.promise f.exited.resolve() await rejected - expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure', recovery: 'restart' }]) + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) await f.controller.close() }) diff --git a/apps/desktop/tests/expected/startup-en-plugins.txt b/apps/desktop/tests/expected/startup-en-plugins.txt deleted file mode 100644 index 29bfba4506..0000000000 --- a/apps/desktop/tests/expected/startup-en-plugins.txt +++ /dev/null @@ -1,8 +0,0 @@ -DeepSeek Harness could not start -Choose a recovery action below. Disabling third-party plugins retains their files. -Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. -If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. -Failure details -Close and restart -Disable all third-party plugins and retry -Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-configuration.txt b/apps/desktop/tests/expected/startup-en-profile.txt similarity index 100% rename from apps/desktop/tests/expected/startup-en-configuration.txt rename to apps/desktop/tests/expected/startup-en-profile.txt diff --git a/apps/desktop/tests/expected/startup-en-reinstall.txt b/apps/desktop/tests/expected/startup-en-reinstall.txt deleted file mode 100644 index 29bfba4506..0000000000 --- a/apps/desktop/tests/expected/startup-en-reinstall.txt +++ /dev/null @@ -1,8 +0,0 @@ -DeepSeek Harness could not start -Choose a recovery action below. Disabling third-party plugins retains their files. -Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. -If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. -Failure details -Close and restart -Disable all third-party plugins and retry -Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-restart.txt b/apps/desktop/tests/expected/startup-en-restart.txt index 29bfba4506..af2ea1dddc 100644 --- a/apps/desktop/tests/expected/startup-en-restart.txt +++ b/apps/desktop/tests/expected/startup-en-restart.txt @@ -1,8 +1,5 @@ DeepSeek Harness could not start Choose a recovery action below. Disabling third-party plugins retains their files. -Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. Failure details Close and restart -Disable all third-party plugins and retry -Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-zh-CN-plugins.txt b/apps/desktop/tests/expected/startup-zh-CN-plugins.txt deleted file mode 100644 index f37bdf417f..0000000000 --- a/apps/desktop/tests/expected/startup-zh-CN-plugins.txt +++ /dev/null @@ -1,8 +0,0 @@ -DeepSeek Harness 无法启动 -请选择下方的恢复操作。禁用第三方插件会保留插件文件。 -重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 -如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 -Failure details -关闭并重启 -禁用全部第三方插件并重试 -重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-configuration.txt b/apps/desktop/tests/expected/startup-zh-CN-profile.txt similarity index 100% rename from apps/desktop/tests/expected/startup-zh-CN-configuration.txt rename to apps/desktop/tests/expected/startup-zh-CN-profile.txt diff --git a/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt b/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt deleted file mode 100644 index f37bdf417f..0000000000 --- a/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt +++ /dev/null @@ -1,8 +0,0 @@ -DeepSeek Harness 无法启动 -请选择下方的恢复操作。禁用第三方插件会保留插件文件。 -重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 -如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 -Failure details -关闭并重启 -禁用全部第三方插件并重试 -重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-restart.txt b/apps/desktop/tests/expected/startup-zh-CN-restart.txt index f37bdf417f..c9fcb94f46 100644 --- a/apps/desktop/tests/expected/startup-zh-CN-restart.txt +++ b/apps/desktop/tests/expected/startup-zh-CN-restart.txt @@ -1,8 +1,5 @@ DeepSeek Harness 无法启动 请选择下方的恢复操作。禁用第三方插件会保留插件文件。 -重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 Failure details 关闭并重启 -禁用全部第三方插件并重试 -重置 Desktop 并重试 diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 65a651d45e..2b2e47d66d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -77,6 +77,7 @@ const harness = await vi.hoisted(async () => { dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), assertProfileRuntime: vi.fn(), + canRecoverProfile: vi.fn(() => true), get preparing() { return preparing }, get prepared() { return prepared }, get hostStarted() { return hostStarted }, get navigated() { return navigated }, get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted }, @@ -108,7 +109,7 @@ vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { readonly applyRelease = harness.applyRelease readonly assertProfileRuntime = harness.assertProfileRuntime - hasEnabledPlugins() { return harness.pluginsEnabled } + canRecoverProfile = harness.canRecoverProfile async mutate(_mutation: unknown, hooks: { beforeChange(): Promise; afterChange(): Promise }) { await hooks.beforeChange() harness.pluginsEnabled = false @@ -145,6 +146,8 @@ afterEach(async () => { for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() } harness.app.quit() await harness.quitCompleted.promise + vi.restoreAllMocks() + harness.canRecoverProfile.mockReturnValue(true) vi.clearAllTimers() vi.useRealTimers() vi.unstubAllEnvs() @@ -152,6 +155,43 @@ afterEach(async () => { }) describe('desktop main startup', () => { + it('exits with a diagnostic when both initialization and emergency navigation fail', async () => { + const exited = Promise.withResolvers() + vi.spyOn(harness.app, 'getLocale').mockImplementationOnce(() => { throw new Error('locale unavailable') }) + vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('emergency navigation failed')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + harness.app.exit.mockImplementationOnce(() => { exited.resolve(undefined) }) + await import('../src/main.ts') + await exited.promise + expect(harness.app.exit).toHaveBeenCalledWith(1) + expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' })) + }) + + it('withholds profile recovery after application resources fail to load', async () => { + harness.canRecoverProfile.mockReturnValue(false) + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.reject(new Error('runtime resources missing')) + await harness.errorPublished.promise + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: false }) + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + const html = decodeURIComponent(window.urls.at(-1)!) + expect(html).toContain('dsh-recovery://restart') + expect(html).not.toContain('dsh-recovery://reset') + expect(html).not.toContain('dsh-recovery://plugins') + }) + + it('reloads a crashed startup renderer in the same window', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('render-process-gone', {}, { reason: 'crashed' }) + await harness.errorPublished.promise + expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://shell/startup.html']) + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' }) + }) + it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => { await import('../src/main.ts') await harness.preparing.promise @@ -214,7 +254,7 @@ describe('desktop main startup', () => { harness.hosts[0]!.exited.resolve() harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed')) await harness.errorPublished.promise - expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ recovery: 'plugins' }) + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true }) const nextStarted = harness.nextHostStart() const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll)) await nextStarted @@ -293,7 +333,7 @@ describe('desktop main startup', () => { first.ready.reject(new Error('plugin composition failed')) await harness.errorPublished.promise await failedRetry - expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', recovery: 'restart' }) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', profileRecovery: true }) expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html']) const nextStarted = harness.nextHostStart() const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index d6e7a63b07..64047c0c0c 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -21,15 +21,12 @@ it('provides startup controls and a removable state subscription to shell docume const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi await api.locale() await api.backend.status() - await api.backend.retry() - await api.openPlugins() await api.disablePlugins() await api.resetConfiguration() await api.restart() - await api.close() expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ - [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen], - [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], [DESKTOP_IPC.applicationClose], + [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], + [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], ]) const listener = vi.fn() const dispose = api.backend.subscribe(listener) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 557e3f62e8..14c6cd753d 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -70,13 +70,13 @@ afterEach(async () => { }) describe('desktop external plugin profile', () => { - it('starts with disabled plugins even when their installed manifests are corrupt', async () => { + it('reuses plugin files without scanning manifests and can disable or reset them', async () => { const { manager } = setup() await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json') writeFileSync(manifest, '{broken') - await expect(manager.applyRelease()).rejects.toThrow() + await expect(manager.applyRelease()).resolves.toBe(false) await manager.mutate({ type: 'plugins-disable-all' }, hooks()) await expect(manager.applyRelease()).resolves.toBe(false) expect(readFileSync(manifest, 'utf8')).toBe('{broken') @@ -119,7 +119,7 @@ describe('desktop external plugin profile', () => { mkdirSync(shared) writeFileSync(join(shared, 'sentinel'), 'preserve') symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir') - await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'configuration' }) + await expect(manager.applyRelease()).rejects.toThrow() await manager.resetConfiguration(hooks({ beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') }, afterChange: async () => { @@ -146,7 +146,8 @@ describe('desktop external plugin profile', () => { it('reports damaged application metadata as a reinstall failure', async () => { const { manager } = setup() writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken') - await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'reinstall' }) + await expect(manager.applyRelease()).rejects.toThrow() + expect(manager.canRecoverProfile()).toBe(false) }) it('accepts registry names and tags but rejects alternate sources and flags', () => { @@ -157,6 +158,58 @@ describe('desktop external plugin profile', () => { } }) + it('retries installation after an interrupted runtime rebuild removed plugin files', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + const failing = join(root, 'fail-install.mjs') + writeFileSync(failing, 'process.exitCode = 1') + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing }) + await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1') + expect(existsSync(join(manager.paths.profile, 'node_modules/plugin'))).toBe(false) + const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(retry.applyRelease()).resolves.toBe(true) + expect(retry.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + await expect(retry.applyRelease()).resolves.toBe(false) + }) + + it('preserves unknown files when initializing a profile', async () => { + const { manager } = setup() + mkdirSync(manager.paths.profile, { recursive: true }) + writeFileSync(join(manager.paths.profile, '.DS_Store'), 'metadata') + writeFileSync(join(manager.paths.profile, 'user-file'), 'retain') + await expect(manager.applyRelease()).resolves.toBe(true) + expect(readFileSync(join(manager.paths.profile, '.DS_Store'), 'utf8')).toBe('metadata') + expect(readFileSync(join(manager.paths.profile, 'user-file'), 'utf8')).toBe('retain') + }) + + it.each(['plugin-add', 'runtime-change'] as const)('retries failed rebuild after %s across manager instances', async (operation) => { + const { root, manager } = setup() + await manager.applyRelease() + let dsh = manager.runtime.dsh + if (operation === 'runtime-change') { + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + } + const failing = join(root, 'fail-rebuild.mjs') + writeFileSync(failing, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); if (process.argv.includes('rebuild')) process.exitCode = 1`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing }) + if (operation === 'plugin-add') { + await worker.applyRelease() + await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())).rejects.toThrow('pnpm exited with 1') + } else await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1') + expect(() => { worker.assertProfileRuntime(worker.paths.profile) }).toThrow('package preparation is incomplete') + const count = calls(root).length + const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(retry.applyRelease()).resolves.toBe(true) + expect(calls(root).slice(count).map(call => call.args.find(arg => !arg.startsWith('--config.')))).toEqual(['install', 'rebuild']) + await expect(retry.applyRelease()).resolves.toBe(false) + expect(calls(root)).toHaveLength(count + 2) + }) + it('initializes and restarts offline without executing pnpm', async () => { const { root, manager } = setup() await expect(manager.applyRelease()).resolves.toBe(true) @@ -207,6 +260,8 @@ describe('desktop external plugin profile', () => { expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true) expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } }) await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(calls(root)).toHaveLength(2) }) it('retains disabled plugin versions through updates and enables them explicitly', async () => { diff --git a/apps/desktop/tests/startup-renderer.spec.ts b/apps/desktop/tests/startup-renderer.spec.ts index 584c9f9587..0c644a21e6 100644 --- a/apps/desktop/tests/startup-renderer.spec.ts +++ b/apps/desktop/tests/startup-renderer.spec.ts @@ -16,23 +16,18 @@ function startup(locale = 'en', status: Promise = Promise.r }) const listeners = new Set<(state: DesktopBackendState) => void>() const unsubscribe = vi.fn(() => { listeners.clear() }) - const retry = vi.fn(async () => {}) - const openPlugins = vi.fn(async () => {}) const disablePlugins = vi.fn(async () => {}) const resetConfiguration = vi.fn(async () => {}) const restart = vi.fn(async () => {}) - const close = vi.fn(async () => {}) const queried = Promise.withResolvers() const api: DshDesktopStartupApi = { protocolVersion: 1, locale: async () => resolveDesktopLocale(locale), backend: { status: () => { queried.resolve(undefined); return status }, - retry, subscribe: (listener) => { listeners.add(listener); return unsubscribe }, }, - openPlugins, - disablePlugins, resetConfiguration, restart, close, + disablePlugins, resetConfiguration, restart, } Object.defineProperty(dom.window, 'dshDesktop', { value: api }) runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) @@ -54,8 +49,8 @@ function startup(locale = 'en', status: Promise = Promise.r ? [...document.querySelectorAll('#actions button')].filter(button => !button.hidden).map(button => button.textContent) : [element(selector).textContent]), ].join('\n') - return { dom, document, element, button, publish, copy, retry, openPlugins, - disablePlugins, resetConfiguration, restart, close, unsubscribe, queried: queried.promise } + return { dom, document, element, button, publish, copy, + disablePlugins, resetConfiguration, restart, unsubscribe, queried: queried.promise } } it('shows English loading and recovery actions without a Host document', async () => { @@ -70,7 +65,7 @@ it('shows English loading and recovery actions without a Host document', async ( expect(page.element('#actions').hidden).toBe(true) expect(page.button('#restart').disabled).toBe(true) expect(page.button('#disable-plugins').disabled).toBe(true) - page.publish({ phase: 'error', message: 'Plugin failed to load' }) + page.publish({ phase: 'error', profileRecovery: true, message: 'Plugin failed to load' }) expect(page.copy()).toMatchInlineSnapshot(` "DeepSeek Harness could not start Choose a recovery action below. Disabling third-party plugins retains their files. @@ -98,7 +93,7 @@ it('shows Chinese loading and recovery copy', async () => { "正在启动 DeepSeek Harness… 准备就绪后将自动打开工作区。" `) - page.publish({ phase: 'error', message: '插件加载失败' }) + page.publish({ phase: 'error', profileRecovery: true, message: '插件加载失败' }) expect(page.copy()).toMatchInlineSnapshot(` "DeepSeek Harness 无法启动 请选择下方的恢复操作。禁用第三方插件会保留插件文件。 @@ -115,7 +110,7 @@ it('renders diagnostic markup as text and exposes failures from recovery actions const page = startup() await expect.poll(() => page.element('#title').textContent).not.toBe('') const diagnostic = '' - page.publish({ phase: 'error', message: diagnostic }) + page.publish({ phase: 'error', profileRecovery: true, message: diagnostic }) expect(page.element('#error').textContent).toBe(diagnostic) expect(page.element('#error').childElementCount).toBe(0) page.restart.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) @@ -129,7 +124,7 @@ it('keeps subscribed state when initial status arrives late and detaches on page const initial = Promise.withResolvers() const page = startup('en', initial.promise) await page.queried - page.publish({ phase: 'error', message: 'Fresh startup failure' }) + page.publish({ phase: 'error', profileRecovery: true, message: 'Fresh startup failure' }) initial.resolve({ phase: 'starting' }) await initial.promise expect(page.element('#error').textContent).toBe('Fresh startup failure') @@ -145,24 +140,24 @@ it('keeps subscribed state when initial status arrives late and detaches on page it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => { const page = startup(locale) await expect.poll(() => page.button('#restart').disabled).toBe(true) - for (const recovery of ['plugins', 'configuration', 'reinstall', 'restart'] as const) { - page.publish({ phase: 'error', recovery, message: 'Failure details' }) - await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-${recovery}.txt`, import.meta.url))) - expect(page.button('#disable-plugins').hidden).toBe(false) - expect(page.button('#reset-configuration').hidden).toBe(false) - const action = recovery === 'plugins' ? '#disable-plugins' : recovery === 'configuration' - ? '#reset-configuration' : '#restart' + page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' }) + await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-profile.txt`, import.meta.url))) + for (const action of ['#disable-plugins', '#reset-configuration', '#restart']) { + page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' }) page.button(action).click() expect(page.element('#actions').hidden).toBe(true) } expect(page.disablePlugins).toHaveBeenCalledOnce() expect(page.resetConfiguration).toHaveBeenCalledOnce() - expect(page.close).not.toHaveBeenCalled() - expect(page.restart).toHaveBeenCalledTimes(2) + expect(page.restart).toHaveBeenCalledOnce() + page.publish({ phase: 'error', profileRecovery: false, message: 'Failure details' }) + expect(page.button('#disable-plugins').hidden).toBe(true) + expect(page.button('#reset-configuration').hidden).toBe(true) + await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-restart.txt`, import.meta.url))) }) it('keeps emergency diagnostics inert without shell assets', () => { - const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '') + const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '', true) const dom = new JSDOM(html) expect(dom.window.document.querySelector('script')).toBeNull() expect(dom.window.document.querySelector('pre')?.textContent).toBe('') @@ -172,3 +167,9 @@ it('keeps emergency diagnostics inert without shell assets', () => { ]) dom.window.close() }) + +it('offers only restart before emergency profile recovery is available', () => { + const dom = new JSDOM(startupFailureDocument(resolveDesktopLocale('en'), 'Resources unavailable')) + expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual(['dsh-recovery://restart']) + dom.window.close() +}) From 82293ff45d1be88865f23a3bfd2bf5d62dcdebb5 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 14:07:51 +0800 Subject: [PATCH 07/19] refactor(desktop): remove unused plugin classification query --- apps/desktop/src/project-manager.ts | 5 ----- apps/desktop/tests/project-manager.spec.ts | 8 ++++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index 195d45efda..a91c6af6b2 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -238,11 +238,6 @@ export class DesktopProjectManager { return pluginRecords(this.paths.profile) } - /** @returns Whether the profile enables any third-party bundle, without loading plugin files. */ - hasEnabledPlugins(): boolean { - return existsSync(this.paths.profile) && profilePluginNames(this.paths.profile).length > 0 - } - /** * Reinitialize the profile, deleting configuration and third-party packages without a backup. * @param hooks - Stop the Host before resetting files; restart after preparation succeeds. diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 14c6cd753d..c618e69393 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -92,9 +92,13 @@ describe('desktop external plugin profile', () => { const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml') unlinkSync(patch) await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { - expect(manager.hasEnabledPlugins()).toBe(false) + expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as { + dsh: { profile: { bundles: string[] } } + }).dsh.profile.bundles).not.toContain('plugin') } })) - expect(manager.hasEnabledPlugins()).toBe(false) + expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as { + dsh: { profile: { bundles: string[] } } + }).dsh.profile.bundles).not.toContain('plugin') expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) expect(calls(root)).toHaveLength(2) await expect(manager.applyRelease()).resolves.toBe(false) From 4c024cf252eb42973f04ad5d87b5270c1359fc8e Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 14:28:18 +0800 Subject: [PATCH 08/19] fix: preserve early process cancellation and sample stream markers promptly --- ...28-subprocess-native-containment.i18n.yaml | 4 +- ...026-08-28-subprocess-native-containment.md | 2 +- ...-08-28-subprocess-native-containment.zh.md | 2 +- ...-06-frontend-performance-budgets.i18n.yaml | 4 +- ...2026-09-06-frontend-performance-budgets.md | 2 +- ...6-09-06-frontend-performance-budgets.zh.md | 2 +- .../long-session-browser/README.i18n.yaml | 4 +- benchmarks/long-session-browser/README.md | 2 +- benchmarks/long-session-browser/README.zh.md | 2 +- .../long-session.bench.ts | 43 ++++++++++++++++--- .../shell/bash-sandbox/tests/sandbox.spec.ts | 12 +++++- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 + .../subprocess/subprocess-local/README.zh.md | 2 + .../subprocess-local/src/linux-scope.ts | 4 +- .../tests/linux-scope.spec.ts | 20 ++++++++- .../subprocess-local/tests/local.spec.ts | 4 +- 17 files changed, 88 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index 805b1f08c0..d0bac9f2ab 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-08-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 0c03884bba67dab6e2e38f96ce2e874ed62ba00f -2026-08-28-subprocess-native-containment.zh.md: b7850e1c4fee06dfeb1a05c968d132de5994686f +2026-08-28-subprocess-native-containment.md: ed1f127f6d03106339472549101f3c089bc05bbe +2026-08-28-subprocess-native-containment.zh.md: 7a0c07b140a4559f4abefbaf0f201050b4776161 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 0c03884bba..ed1f127f6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor. -Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with the startup failure while range observation records that the scope never existed and resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion. +Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits without a signal while the request remains unconsumed, the direct result rejects with the startup failure. A signal exit preserves the observed signal even before request consumption: cancellation and deadlines can stop the bootstrap before the target executes. An explicit bootstrap error still takes precedence over the signal. Range observation independently proves the scope empty; an absent unit plus a stopped launcher resolves the empty-range wait. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion. The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index b7850e1c4f..7a0c07b140 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。 -request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,而 range observation 会记录 scope 从未存在,并成功结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 +request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 非信号退出时 request 仍未消费,direct result 会以 startup failure reject。信号退出即使发生在 request 消费前,也保留观察到的信号:取消与 deadline 可以在 target 执行前停止 bootstrap。明确的 bootstrap error 仍优先于信号。range observation 独立证明 scope 为空;unit 不存在且 launcher 已停止时,empty-range wait 成功结算。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 20015c92fe..ac30e2d61d 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml @@ -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/testing/2026-09-06-frontend-performance-budgets.md -2026-09-06-frontend-performance-budgets.md: 4c1a99e10c9b2d7ba7ecbd38f2249ac84e9d330b -2026-09-06-frontend-performance-budgets.zh.md: f563c55e6440de85d71110cbfb2533c7f99218ce +2026-09-06-frontend-performance-budgets.md: 4d69dda8a04d4e9807c18c8f1b978ca7fc87a883 +2026-09-06-frontend-performance-budgets.zh.md: c9a174df55a2c175232eb2f4d8468b054e532b9c diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 4c1a99e10c..4d69dda8a0 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -16,7 +16,7 @@ The existing serial benchmark inventory includes two frontend owners: [active re The browser input contains 240 closed turns, 40 tool results, and 20 code fences, plus mixed-language prose and reasoning. Historical Assistant records carry matching compact streams built through the production accumulator with 12-character reasoning/text deltas and 8-character tool-argument deltas; empty streams would omit stored and transferred payload costs. Nine older-page actions exhaust this input from its observed 25-turn initial window; the readiness probe follows mounted turn growth rather than duplicating the pagination algorithm. Each sample uses a fresh scaffold and browser. Setup, seeding, browser launch, initial shell load, and sidebar expansion are excluded from open timing. Open ends at transcript availability and an editable composer; page and navigation timings end at their target DOM state. Two animation frames include a rendering opportunity, not hardware presentation or a guarantee that every offscreen node painted. -The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups stay inside the latest Assistant step and retain visible-state waits. Diagnostics capture reply markers and focus immediately after the first-visible wait, plus browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak. +The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups sample visible text inside the latest Assistant step on animation frames, avoiding selector retry backoff. Hidden text and markers in older steps cannot satisfy the observer. The first observation captures reply markers and focus in the browser; diagnostics are retrieved after typing to avoid an additional pre-input round trip. Diagnostics also capture browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak. Reconnect uses three fresh compiled plain-Node children. Each creates a 100,000-delta reasoning prefix with distinct timestamps and two compact records before timing `ClientAssistantStream.replace()`. GC precedes the baseline and follows replacement while the result remains reachable; replacement time excludes both collections. The report consumes the result after collection and checks that the next dense live frame remains accepted. This measures reconstruction, not transport, rendering, or an entire reconnect workflow. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index f563c55e64..c9a174df55 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -16,7 +16,7 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 浏览器输入包含 240 个已关闭轮次、40 个工具结果和 20 个代码块,以及混合语言正文和推理。历史 Assistant 记录携带匹配的紧凑 stream,通过生产 accumulator 按 12 字符推理/文本 delta 和 8 字符工具参数 delta 构建;空 stream 会遗漏存储与传输负载成本。从观察到的初始 25 轮窗口开始,九次更早分页操作读完该输入;就绪探针跟踪已挂载轮次增长,不复制分页算法。每个样本使用全新 scaffold 和浏览器。环境准备、数据播种、浏览器启动、初始 shell 加载及侧栏展开不计入打开时间。打开测量在对话可用且输入框可编辑时结束;分页与导航测量在目标 DOM 状态出现时结束。两次动画帧包含一次渲染机会,不代表硬件显示或保证每个屏幕外节点都已绘制。 -续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。诊断在首段可见等待后立即记录回复标记和焦点,并记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。 +续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找在动画帧上采样最新 Assistant step 内的可见文本,避免选择器重试退避。隐藏文本和较早步骤中的标记均不能满足观察条件。首次观察在浏览器内记录回复标记与焦点;诊断在键入后取回,避免增加输入前的通信往返。诊断还记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。 重连使用三个全新编译后的纯 Node 子进程。各进程在计时 `ClientAssistantStream.replace()` 前创建包含不同时间戳、两条紧凑记录和 100,000 个 delta 的推理前缀。在基线前执行 GC,并在结果仍可达时于替换后再次 GC;替换时间不含两次回收。报告在回收后消费结果,并检查下一个稠密序号的实时 frame 仍被接受。这测量重建,不测量传输、渲染或完整重连工作流。 diff --git a/benchmarks/long-session-browser/README.i18n.yaml b/benchmarks/long-session-browser/README.i18n.yaml index 004876c403..060c1346dc 100644 --- a/benchmarks/long-session-browser/README.i18n.yaml +++ b/benchmarks/long-session-browser/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 benchmarks/long-session-browser/README.md -README.md: 009a7a61d9a1a0d4b198ae017def5d3126b713a2 -README.zh.md: df1da8fd76c2f4fa926b6348fa631530fb98181a +README.md: d6bd253dedb2c6cdea7fec1019a14bfcc3c4c56f +README.zh.md: fe593b833002ece8efd18d07707c10708cb76e2e diff --git a/benchmarks/long-session-browser/README.md b/benchmarks/long-session-browser/README.md index 009a7a61d9..d6bd253ded 100644 --- a/benchmarks/long-session-browser/README.md +++ b/benchmarks/long-session-browser/README.md @@ -10,7 +10,7 @@ The required Chromium workflow in [long-session.bench.ts](long-session.bench.ts) ## Measurements -Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Diagnostics report marker state and focus after the first-visible wait, and browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. +Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Reply markers are sampled on animation frames, with visible text required inside the latest step. The first observation captures marker state and focus in the browser; its diagnostics are retrieved after typing so they add no pre-input round trip. Diagnostics also include browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. The fixture reserves an empty system head before the first user message, with each user message inside its step. It contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence. diff --git a/benchmarks/long-session-browser/README.zh.md b/benchmarks/long-session-browser/README.zh.md index df1da8fd76..fe593b8330 100644 --- a/benchmarks/long-session-browser/README.zh.md +++ b/benchmarks/long-session-browser/README.zh.md @@ -10,7 +10,7 @@ ## 测量 -三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。诊断报告等待首次可见回复后的标记状态与焦点,以及首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 +三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。回复标记在动画帧上采样,要求文本在最新步骤内可见。首次观察在浏览器内记录标记状态与焦点;诊断在键入后取回,不增加输入前的通信往返。诊断还包含首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 fixture(测试前置数据)在首条用户消息前保留空 system 头节点,每条用户消息都位于其步骤内。它包含混合语言提示词、正文、推理(reasoning)、20 个围栏代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑流,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish 分片构建。其内容不来自模型、工具、外部网络、录制会话或私有 Harness 主目录。流式回复以 16 ms 回放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。 diff --git a/benchmarks/long-session-browser/long-session.bench.ts b/benchmarks/long-session-browser/long-session.bench.ts index c43ec05814..7c5e1ef9ca 100644 --- a/benchmarks/long-session-browser/long-session.bench.ts +++ b/benchmarks/long-session-browser/long-session.bench.ts @@ -52,6 +52,22 @@ function expectInputOverlap(value: boolean): void { expect(value).toBe(true) } +async function waitForReplyMarker(page: Page, marker: string, timeout = 30000) { + return page.waitForFunction(({ marker, first, done }) => { + const reply = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1) + if (!reply) return false + const text = document.createTreeWalker(reply, NodeFilter.SHOW_TEXT) + let node: Node | null + while ((node = text.nextNode())) { + if (!node.textContent?.includes(marker) || !node.parentElement?.checkVisibility({ checkVisibilityCSS: true })) continue + const composer = Array.from(document.querySelectorAll('[data-composer-input][contenteditable="true"]')).at(-1) + const transcript = reply.textContent ?? '' + return { atMs: window.performance.now(), focused: document.activeElement === composer, first: transcript.includes(first), done: transcript.includes(done) } + } + return false + }, { marker, first: FIRST, done: DONE }, { polling: 'raf', timeout }) +} + async function watchInputOverlap(composer: Locator): Promise { await composer.evaluate((element, markers) => { element.removeAttribute('data-benchmark-input-witness') @@ -95,6 +111,22 @@ it('accepts recorded hosted paging and Trajectory medians and rejects slower end } }) +it('waits for visible marker text in the latest Assistant step', async () => { + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.setContent(`
${FIRST}
${FIRST}
`) + await expect(waitForReplyMarker(page, FIRST, 100)).rejects.toThrow('Timeout') + await page.locator('span').evaluate(element => { element.style.visibility = 'visible' }) + const observation = await waitForReplyMarker(page, FIRST) + expect(await observation.jsonValue()).toMatchObject({ first: true, done: false }) + await observation.dispose() + await expect(waitForReplyMarker(page, DONE, 100)).rejects.toThrow('Timeout') + } finally { + await browser.close() + } +}) + it('opens, pages, navigates and streams into a 240-turn browser history', async () => { if (webSnapshotMode() !== 'replay') throw new Error('browser benchmarks require keyless replay mode') const samples: { open: number; page: number; trajectory: number; first: number; streamTask: number; streamWall: number; input: number; inputOverlapped: boolean; heapMb: number; nodes: number }[] = [] @@ -157,22 +189,19 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async await watchInputOverlap(composer) const started = performance.now() await page.keyboard.press('Enter') - const reply = page.locator('[data-chat-flow-kind="assistant-step"]').last() - await reply.getByText(FIRST, { exact: false }).last().waitFor() + const firstMarker = await waitForReplyMarker(page, FIRST) const first = performance.now() - started - const firstObservation = await composer.evaluate((element, markers) => { - const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? '' - return { atMs: window.performance.now(), focused: document.activeElement === element, first: transcript.includes(markers.first), done: transcript.includes(markers.done) } - }, { first: FIRST, done: DONE }) // Keep focus across submission; mouse actionability must not delay the input probe. const input = await measure(page, async () => { await page.keyboard.type('next synthetic question') await expect.poll(() => composer.textContent()).toBe('next synthetic question') }) const inputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true' + const firstObservation = await firstMarker.jsonValue() + await firstMarker.dispose() console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, firstObservation, witness: await composer.getAttribute('data-benchmark-input-witness'), inputTiming: await composer.getAttribute('data-benchmark-input-timing') })) expectInputOverlap(inputOverlapped) - await reply.getByText(DONE, { exact: false }).last().waitFor() + await (await waitForReplyMarker(page, DONE)).dispose() const settlement = await settled if (!settlement.ok) throw settlement.error await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS + 1 }) diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index f6a57b541c..b85900f602 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -661,8 +661,16 @@ describe('background sandbox facts', () => { it('disposal kills wrapped background jobs (inherited HMR safety)', async () => { const { ctx, bash } = await setup() - const task = bash.start(bash.resolve({ command: 'sleep 30' })) - await ctx.fiber.dispose() + const task = bash.start(bash.resolve({ command: 'echo ready; sleep 30' })) + let output = '' + try { + await expect.poll(() => { + output += task.readOutput().delta + return output + }).toContain('ready') + } finally { + await ctx.fiber.dispose() + } expect(task.status).toBe('killed') }) }) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index b06be2b9dd..31e3a3b508 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: 63d4d5d403d3559778dcad76a8c96cf677f1a977 -README.zh.md: b876ca9d3a33b37557229390b7f6039ba4498b96 +README.md: 9cd8544e2d5970ec8ca9828f886099279db0ecfd +README.zh.md: 86897e47dcb65dd59ec3143a6f5cb7a8fa30dd1a diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 63d4d5d403..9cd8544e2d 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -92,6 +92,8 @@ Each spawn selects one owner for both signalling and quiescence. Supported Linux A spawn synchronously validates the final argv, cwd, and environment, selects containment before the user command can run, and returns a handle while target identity remains private. Linux ordinary and terminal launches use a private one-shot request whose scoped bootstrap restores the target cwd and environment, resolves the executable, clears close-on-exec on fd 0 through fd 2, and enters libc `execve()` with the original argv. Windows ordinary launches isolate runner fd 0 through fd 2, reserve fd 3 for IPC, and carry target stdio on fd 4 through fd 6; the runner resolves those CRT descriptors to OS handles, creates the target suspended, assigns it to the Job, resumes it, and closes only the carrier descriptors. `done` settles the direct command after its stdio barrier, while `waitForExit()` separately waits for the selected scope, Job, process group, or observed session to become empty. +A Linux launcher terminated by a signal reports that signal even before its bootstrap consumes the target request; an explicit bootstrap error still rejects `done`. This preserves cancellation and timeout results without treating an unsignaled bootstrap failure as success. + ### Safety invariants Spill files are opened `0600` with `O_EXCL` and random names under a `0700` per-process directory, defeating symlink planting in shared temp dirs; a failed final close withholds the spill path. Fallback process identities carry start times, so cleanup never follows PID reuse. A selected native failure is reported instead of replaying argv through fallback, and a range is removed from the live set only after cleanup completes or the failure remains observable. Host-exit finalization creates no promises or timers, preserves the host exit code and diagnostic, contains each target's failure, and does not claim quiescence. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index b876ca9d3a..86897e47dc 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -92,6 +92,8 @@ kind: "package-reference" 一次 spawn 会同步校验最终 argv、cwd 与环境,在用户命令可能运行前选择 containment,并在目标身份保持私有的情况下返回句柄。Linux 普通命令与终端启动使用私有的一次性请求;scope 内的 bootstrap 会恢复目标 cwd 与环境、解析可执行文件、清除 fd 0 至 fd 2 的 close-on-exec 标记,再以原始 argv 进入 libc `execve()`。Windows 普通命令会隔离 runner 的 fd 0 至 fd 2、把 fd 3 留给 IPC,并用 fd 4 至 fd 6 承载 target stdio;runner 把这些 CRT 描述符解析成 OS handle,以 suspended 状态创建 target,将其加入 Job、恢复运行,再只关闭 carrier 描述符。`done` 会在 direct command 及其 stdio 屏障结算后完成,`waitForExit()` 则分别等待所选 scope、Job、进程组或已观察会话变空。 +Linux launcher 被信号终止时,即使 bootstrap 尚未消费 target request,也会报告该信号;明确的 bootstrap error 仍会让 `done` reject。这会保留取消与超时结果,同时不会把非信号退出的 bootstrap failure 当作成功。 + ### 安全不变式 spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目录下创建,可抵御共享临时目录中的符号链接植入;最终关闭失败时不公布 spill 路径。fallback 进程身份携带启动时间,因此清理绝不会跟随 PID 复用。选定的 native 路径失败时会报告错误,而不会通过 fallback 重放 argv;受管范围只有在清理完成后才从存活集合移除,否则失败仍保持可观察。宿主退出最终清理不创建 Promise 或定时器,保留宿主退出码与诊断,分别包含每个目标的失败,也不会声称已经完全停稳。 diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index 98b6847db8..78977a9315 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -376,7 +376,7 @@ function directOutcome( rejectOutcome(deserializeRunnerError(startup.error)) return } - if (existsSync(files.requestPath)) { + if (existsSync(files.requestPath) && signal === null) { rejectOutcome(new Error('subprocess scope exited before its bootstrap consumed the launch request')) return } @@ -441,7 +441,7 @@ export function prepareLinuxTerminalScope( resolveOutcome: (outcome) => { const startup = readLinuxStartupError(files.startupErrorPath) if (startup !== undefined) throw deserializeRunnerError(startup.error) - if (existsSync(files.requestPath)) { + if (existsSync(files.requestPath) && outcome.signal === null) { throw new Error('terminal scope exited before its bootstrap consumed the launch request') } return outcome diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index a392d35d08..358b826868 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -205,7 +205,7 @@ describe('Linux scope establishment and quiescence', () => { expect(spawnSync).toHaveBeenCalledWith('/bin/systemctl', expect.arrayContaining([ 'kill', '--kill-whom=all', '--signal=SIGTERM', ]), expect.anything()) - const direct = expect(result.direct).rejects.toThrow('before its bootstrap consumed') + const direct = expect(result.direct).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' }) child.exit(null, 'SIGTERM') await direct await expect(waiting).resolves.toBeUndefined() @@ -248,6 +248,15 @@ describe('Linux scope establishment and quiescence', () => { result.owner.cleanup?.() }) + it.each(['SIGTERM', 'SIGKILL'] as const)('reports %s before bootstrap consumption as a signal outcome', async (signal) => { + const { child, result, requestPath } = launch(async () => missingUnit()) + expect(existsSync(requestPath)).toBe(true) + child.exit(null, signal) + await expect(result.direct).resolves.toEqual({ exitCode: null, signal }) + await expect(result.owner.waitForExit()).resolves.toBeUndefined() + result.owner.cleanup?.() + }) + it('uses manager-observed unit existence as establishment proof', async () => { const { child, result } = launch(async () => activeUnit('inactive')) await expect(result.owner.waitForExit()).resolves.toBeUndefined() @@ -572,6 +581,15 @@ describe('Linux PTY bootstrap reuse', () => { scope.cleanup() }) + it.each(['SIGTERM', 'SIGKILL'] as const)('preserves PTY %s before bootstrap consumption', (signal) => { + const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }) + try { + expect(scope.resolveOutcome({ exitCode: null, signal })).toEqual({ exitCode: null, signal }) + } finally { + scope.cleanup() + } + }) + it('uses default owner dependencies and rejects an unconsumed request', () => { const scope = prepareLinuxTerminalScope(terminalSpec, { TARGET: 'yes' }) const requestPath = scope.env[SUBPROCESS_RUNNER_ENV] diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 4ca9f64439..3013ddd2cb 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -393,6 +393,7 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType + service.internals = { platform: 'darwin' } const handle = await ctx.subprocess.spawnTerminal({ argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, }) @@ -600,6 +601,7 @@ describe('LocalSubprocessRuntime', () => { ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const alive = new Set([124]) + ;(ctx.subprocess as InstanceType).internals = { platform: 'darwin' } ;(ctx.subprocess as InstanceType).terminalInspector = { foregroundPgid: () => 123, isStdinWaiting: () => false, @@ -617,7 +619,7 @@ describe('LocalSubprocessRuntime', () => { }) exitListener?.({ exitCode: 0 }) await handle.done - await new Promise(resolve => setTimeout(resolve, 10)) + await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') expect((ctx.subprocess as unknown as { terminals: Set }).terminals.size).toBe(1) await fiber.dispose() expect(disposalErrors).toHaveLength(1) From a62fab00b751840789c00e126346573302034593 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 14:33:20 +0800 Subject: [PATCH 09/19] test: distinguish unavailable Windows dialog classes in native smoke --- .../directory-picker-native/README.i18n.yaml | 4 +-- .../host/directory-picker-native/README.md | 2 +- .../host/directory-picker-native/README.zh.md | 2 +- .../tests/win32-dialog.spec.ts | 28 +++++++++++++++---- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index dd7ea6cc66..1920d8bb86 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: 3f980831848965b976e32b0e5ef25abb77fe4e6e -README.zh.md: 7a221cbbfebf3d79534e049800e26dab6ca39ad3 +README.md: ecd66dedf19236cc56db35ec46eb440061b42a26 +README.zh.md: 9c16a145a2e78145980babfcf883d11e9b881646 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 3f98083184..ecd66dedf1 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -97,7 +97,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the native interaction is unavailable or fragile. They are current package constraints, not a task backlog. - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). -- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level. +- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level. The real-worker smoke accepts normal abort closure or the specific FileOpenDialog class-unavailable HRESULT `0x80040111`; that refusal validates error propagation, not successful dialog display. Other native errors and forced termination fail the smoke. - **Windows foreground grant relies on injected input** — the child synthesizes an Alt press before `Show` so the dialog can take the foreground from a background host; where synthesized input is suppressed (secure desktops, restricted remote sessions, an elevated foreground window), the dialog may still open behind other windows. The technique is validated on Windows 11 only. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 7a221cbbfe..9c16a145a2 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -97,7 +97,7 @@ kind: "package-reference" 这些限制说明原生交互何时不可用或不稳定。它们是当前包约束,不是任务积压。 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是浏览后端)。 -- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。 +- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。真实 worker smoke 接受正常取消关闭,或 FileOpenDialog 类不可用的特定 HRESULT `0x80040111`;该拒绝结果只验证错误传递,不证明对话框成功显示。其他原生错误和强制终止均使 smoke 失败。 - **Windows 前台授权依赖注入的输入**——子进程在 `Show` 之前合成一次 Alt 按键,对话框才能从后台宿主取得前台;在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍可能在其他窗口后面打开。该技术仅在 Windows 11 上验证过。 diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 8e7d6951b8..5ea6ab91b8 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -3,7 +3,7 @@ * the WM_CLOSE abort service (including the show-race retry and the kill * last resort) against fakes, plus the real spawn plumbing — POSIX hosts * prove the default path rejects cleanly (koffi cannot load ole32 there), - * and win32 hosts briefly open and auto-abort a real dialog. + * and win32 hosts auto-abort a real dialog or report COM class unavailability. */ import { EventEmitter } from 'node:events' @@ -40,6 +40,10 @@ function harness(overrides: Partial = {}): Harness { } const live = (): AbortSignal => new AbortController().signal +const REAL_DIALOG_REJECTION = new RegExp([ + '^(?:native directory picker aborted|', + String.raw`win32 folder dialog failed: Error: CoCreateInstance\(FileOpenDialog\) failed: HRESULT 0x80040111)(?:\r?\n|$)`, +].join('')) describe('pickWin32Directory', () => { it('resolves the selected path and the cancellation null', async () => { @@ -151,13 +155,25 @@ describe('pickWin32Directory', () => { await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') }, 30_000) - // win32 hosts run the true COM smoke instead: a real dialog opens briefly - // and the abort service closes it (the same lever a disconnecting client pulls). - it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { + it('rejects unrelated native failures and forced termination as dialog smoke outcomes', () => { + expect('native directory picker aborted').toMatch(REAL_DIALOG_REJECTION) + expect('win32 folder dialog failed: Error: CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80040111\n at createFolderDialog').toMatch(REAL_DIALOG_REJECTION) + for (const message of [ + 'win32 folder dialog failed: Error: CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005', + 'win32 folder dialog worker exited before reporting a result', + 'native directory picker aborted (dialog unresponsive; worker killed)', + ]) expect(message).not.toMatch(REAL_DIALOG_REJECTION) + }) + + it.skipIf(process.platform !== 'win32')('abort-closes a real dialog or reports its unavailable COM class', async () => { const controller = new AbortController() - setTimeout(() => { + const timer = setTimeout(() => { controller.abort() }, 400) - await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted') + try { + await expect(pickWin32Directory(controller.signal)).rejects.toThrow(REAL_DIALOG_REJECTION) + } finally { + clearTimeout(timer) + } }, 30_000) }) From 6d103f8f7c828f8e82b16a96529e2e7394f0dc89 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 15:08:38 +0800 Subject: [PATCH 10/19] Revert Windows dialog smoke acceptance of unavailable COM classes --- .../directory-picker-native/README.i18n.yaml | 4 +-- .../host/directory-picker-native/README.md | 2 +- .../host/directory-picker-native/README.zh.md | 2 +- .../tests/win32-dialog.spec.ts | 28 ++++--------------- 4 files changed, 10 insertions(+), 26 deletions(-) diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 1920d8bb86..dd7ea6cc66 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: ecd66dedf19236cc56db35ec46eb440061b42a26 -README.zh.md: 9c16a145a2e78145980babfcf883d11e9b881646 +README.md: 3f980831848965b976e32b0e5ef25abb77fe4e6e +README.zh.md: 7a221cbbfebf3d79534e049800e26dab6ca39ad3 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index ecd66dedf1..3f98083184 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -97,7 +97,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the native interaction is unavailable or fragile. They are current package constraints, not a task backlog. - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). -- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level. The real-worker smoke accepts normal abort closure or the specific FileOpenDialog class-unavailable HRESULT `0x80040111`; that refusal validates error propagation, not successful dialog display. Other native errors and forced termination fail the smoke. +- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level. - **Windows foreground grant relies on injected input** — the child synthesizes an Alt press before `Show` so the dialog can take the foreground from a background host; where synthesized input is suppressed (secure desktops, restricted remote sessions, an elevated foreground window), the dialog may still open behind other windows. The technique is validated on Windows 11 only. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 9c16a145a2..7a221cbbfe 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -97,7 +97,7 @@ kind: "package-reference" 这些限制说明原生交互何时不可用或不稳定。它们是当前包约束,不是任务积压。 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是浏览后端)。 -- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。真实 worker smoke 接受正常取消关闭,或 FileOpenDialog 类不可用的特定 HRESULT `0x80040111`;该拒绝结果只验证错误传递,不证明对话框成功显示。其他原生错误和强制终止均使 smoke 失败。 +- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。 - **Windows 前台授权依赖注入的输入**——子进程在 `Show` 之前合成一次 Alt 按键,对话框才能从后台宿主取得前台;在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍可能在其他窗口后面打开。该技术仅在 Windows 11 上验证过。 diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 5ea6ab91b8..8e7d6951b8 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -3,7 +3,7 @@ * the WM_CLOSE abort service (including the show-race retry and the kill * last resort) against fakes, plus the real spawn plumbing — POSIX hosts * prove the default path rejects cleanly (koffi cannot load ole32 there), - * and win32 hosts auto-abort a real dialog or report COM class unavailability. + * and win32 hosts briefly open and auto-abort a real dialog. */ import { EventEmitter } from 'node:events' @@ -40,10 +40,6 @@ function harness(overrides: Partial = {}): Harness { } const live = (): AbortSignal => new AbortController().signal -const REAL_DIALOG_REJECTION = new RegExp([ - '^(?:native directory picker aborted|', - String.raw`win32 folder dialog failed: Error: CoCreateInstance\(FileOpenDialog\) failed: HRESULT 0x80040111)(?:\r?\n|$)`, -].join('')) describe('pickWin32Directory', () => { it('resolves the selected path and the cancellation null', async () => { @@ -155,25 +151,13 @@ describe('pickWin32Directory', () => { await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') }, 30_000) - it('rejects unrelated native failures and forced termination as dialog smoke outcomes', () => { - expect('native directory picker aborted').toMatch(REAL_DIALOG_REJECTION) - expect('win32 folder dialog failed: Error: CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80040111\n at createFolderDialog').toMatch(REAL_DIALOG_REJECTION) - for (const message of [ - 'win32 folder dialog failed: Error: CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005', - 'win32 folder dialog worker exited before reporting a result', - 'native directory picker aborted (dialog unresponsive; worker killed)', - ]) expect(message).not.toMatch(REAL_DIALOG_REJECTION) - }) - - it.skipIf(process.platform !== 'win32')('abort-closes a real dialog or reports its unavailable COM class', async () => { + // win32 hosts run the true COM smoke instead: a real dialog opens briefly + // and the abort service closes it (the same lever a disconnecting client pulls). + it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { const controller = new AbortController() - const timer = setTimeout(() => { + setTimeout(() => { controller.abort() }, 400) - try { - await expect(pickWin32Directory(controller.signal)).rejects.toThrow(REAL_DIALOG_REJECTION) - } finally { - clearTimeout(timer) - } + await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted') }, 30_000) }) From 9f27a4540813c4fa0eb8f6fb026954af793b7f3e Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 15:49:57 +0800 Subject: [PATCH 11/19] test: preserve immediate disposal and observe automatic terminal cleanup --- packages/shell/bash-sandbox/tests/sandbox.spec.ts | 12 ++---------- .../subprocess/subprocess-local/tests/local.spec.ts | 7 ++++--- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index b85900f602..f6a57b541c 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -661,16 +661,8 @@ describe('background sandbox facts', () => { it('disposal kills wrapped background jobs (inherited HMR safety)', async () => { const { ctx, bash } = await setup() - const task = bash.start(bash.resolve({ command: 'echo ready; sleep 30' })) - let output = '' - try { - await expect.poll(() => { - output += task.readOutput().delta - return output - }).toContain('ready') - } finally { - await ctx.fiber.dispose() - } + const task = bash.start(bash.resolve({ command: 'sleep 30' })) + await ctx.fiber.dispose() expect(task.status).toBe('killed') }) }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 3013ddd2cb..135638f54a 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -400,8 +400,7 @@ describe('LocalSubprocessRuntime', () => { expect((service as unknown as { terminals: Set }).terminals.size).toBe(1) exitListener?.({ exitCode: 0 }) await handle.done - await new Promise(resolve => setImmediate(resolve)) - expect((service as unknown as { terminals: Set }).terminals.size).toBe(0) + await expect.poll(() => (service as unknown as { terminals: Set }).terminals.size).toBe(0) await fiber.dispose() } finally { vi.doUnmock('node-pty') @@ -617,9 +616,11 @@ describe('LocalSubprocessRuntime', () => { const handle = await ctx.subprocess.spawnTerminal({ argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, }) + const terminate = vi.spyOn(handle, 'terminate') exitListener?.({ exitCode: 0 }) await handle.done - await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') + await expect.poll(() => terminate.mock.calls.length).toBe(1) + await expect(terminate.mock.results[0]?.value).rejects.toThrow('surviving pids: 124') expect((ctx.subprocess as unknown as { terminals: Set }).terminals.size).toBe(1) await fiber.dispose() expect(disposalErrors).toHaveLength(1) From ae7462c5268fd6d75fac6a7bf778ed923c36b3cb Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 10 Sep 2026 16:13:54 +0800 Subject: [PATCH 12/19] test: clarify live SDK smoke completion acknowledgement --- scripts/smoke-python-runtime.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 43d0ec2ccb..139be0f756 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -918,6 +918,8 @@ def smoke_sdk_live() -> None: "Use a tool to read that same file and copy its exact current content to the " "new receipt path below, without changing the source file. " "Preserve every byte; do not add a newline or byte-order mark. " + f"{LIVE_API_SENTINEL} is only the completion acknowledgement, " + "not a claim about the source or receipt contents. " f"Then reply with exactly {LIVE_API_SENTINEL}.\n{receipt}" ) verified = harness.run(verify_prompt, session_id=session_id) From 0718711886e17f95068cd504e58536c7e7bb925b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:17:14 +0800 Subject: [PATCH 13/19] docs(testing): define the native-mock client assembly tier --- ...-09-06-client-assembly-test-line.i18n.yaml | 6 + .../2026-09-06-client-assembly-test-line.md | 90 +++++++++++ ...2026-09-06-client-assembly-test-line.zh.md | 90 +++++++++++ .agents/skills/dsh-pre-push-checks/SKILL.md | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- packages/client/web/README.i18n.yaml | 4 +- packages/client/web/README.md | 4 +- packages/client/web/README.zh.md | 4 +- packages/test-support/README.i18n.yaml | 4 +- packages/test-support/README.md | 1 + packages/test-support/README.zh.md | 1 + .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 43 +++++- .../test-support/client-runtime/README.zh.md | 45 +++++- .../test-support/remote-mock/README.i18n.yaml | 6 + packages/test-support/remote-mock/README.md | 146 ++++++++++++++++++ .../test-support/remote-mock/README.zh.md | 146 ++++++++++++++++++ scripts/doc-standard.spec.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + 30 files changed, 614 insertions(+), 27 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md create mode 100644 .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md create mode 100644 packages/test-support/remote-mock/README.i18n.yaml create mode 100644 packages/test-support/remote-mock/README.md create mode 100644 packages/test-support/remote-mock/README.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml new file mode 100644 index 0000000000..e5675ab2e6 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.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/testing/2026-09-06-client-assembly-test-line.md +2026-09-06-client-assembly-test-line.md: 0d7ed86e4f0c8898441737c8d6a4c631586268a2 +2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md new file mode 100644 index 0000000000..0d7ed86e4f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -0,0 +1,90 @@ +# Agent Note: Whole-client test tier over an endpoint-named Remote mock + +Status: implemented + +English | [中文](2026-09-06-client-assembly-test-line.zh.md) + +## Problem + +Browser feature specs each hand-build their bench: a bare Cordis context, stand-ins for `locale`, `connection`, and `remote`, and slot declarations the real declarer would have made. Their assertions therefore describe the bench, not the product: a plugin that adds a settings section, a declarer that reloads through the Loader, or a Connection that reconnects is invisible to them, and every bench repeats the same forty lines with small drift. + +API client specs drive their objects through a programmable fake of the Remote surface. The fake re-implements Gateway semantics it should only exercise: follow-stream opening snapshots derived from a history list, page cuts, stream pumps with delivery promises, and an envelope layer. Each of those is a second implementation of a contract the product already owns, and it lets tests describe behavior the generated client cannot produce, such as a unary call that rejects. + +No focused source test boots the client the way production does — `bootClient` creating one Loader entry per manifest row, then `mountClient` — so composition faults between plugins are hidden by hand-built benches. + +## Decision + +A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the deep import `src/assembly/`, and a new test-support package `@deepseek-ai/dsh-remote-mock` answers Remote traffic by endpoint name. Both are described by their READMEs ([client-runtime](../../../../packages/test-support/client-runtime/README.md), [remote-mock](../../../../packages/test-support/remote-mock/README.md)); this note records the decisions behind them. + +**The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row. + +**The production boot path runs unchanged.** `TestClient.start(plan, mock)` installs the mock as the Connection carrier (`__DSH_TRANSPORT__ = { rpc: mock.rpc }`), loads each roster row's `/client` module, registers its factory through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` installs inert stand-ins only where the global is absent. Boots and entry rebuilds run one at a time per worker, since the `connection` plugin reads the transport global at apply, and each installs the acting client's transport first; the transport and shims are held by reference count, the first client installing them and the last dispose restoring them, so overlapping clients in one test each connect to their own mock, also after a `reload` of the `connection` row. + +**`remote.` is a contract-free proxy, not the generated client.** The `@deepseek-ai/dsh-api-remotes` row is dropped because its generated clients exist only in built `lib/`. For every `remote.` a roster row injects, plus every namespace the mock has a rule for, the tier provides one Proxy: `ctx.remote..(...args)` calls the endpoint `/` over the roster's own Connection with the positional args, as a stream when the mock registered a stream script for it and as a unary call otherwise. Cordis resolves `ctx.remote.` to the service `remote.`, so the Gateway client itself is untouched. A unary answer returns unchanged; a unary rejection folds the way the generated client folds a carrier throw, through the Gateway client's exported `carrierFailure` and `cancelledFailure`, so product code that fires a Remote call without awaiting sees no rejection. Stream items and failures pass through as the stream yields them. + +**Native mocks own response configuration and call assertions.** Tests use `mock.remote..` with `mockResolvedValue`, `mockResolvedValueOnce`, `mockReturnValueOnce`, or `mockImplementation`; the generated API supplies the signatures. Each mock instance owns its native response queue. Reusable tables register only default values or positional handlers, and each endpoint retains only its latest default. Stateful callbacks and deferred promises belong to individual tests. `ok` builds the success envelope. Streams require explicit declarations and can receive scripts over the opening args and a handle (`push`, `end`, `fail`); a declaration without a script produces a stream miss, while undeclared endpoints default to unary calls. Values are not validated. `mock.streams` controls scripted streams and exposes readiness/drain waits; `mock.log` records carrier calls (`pending`, `answered`, `failed`), scripted-stream state, first-argument `requests(endpoint?)`, and unmatched requests. `RemoteMock.create()` answers `$events` with a ready frame so the client can connect. + +**Vitest owns each test's mock and client lifetime.** `createClientTest(plan, options)` adds native `mock`, `remote`, and `start` fixtures. The mock is fresh and carries the default responses; `remote` is its namespace proxy, and explicit `start()` leaves startup responses configurable and shares one startup promise within the test. Teardown waits for startup, disposes the successful client even after an assertion failure, checks missing responses, and rejects later starts. Callers await startup failures. Independently owned clients still use `TestClient.start`. Scenario data configures native mocks directly; returning a mutation response and updating subsequent describe responses remain separate actions. + +**`remoteDefaultResponses` holds default responses for the boot-time Remote endpoints.** The table lists exactly the endpoints the `web` roster calls while booting and rendering with no sessions, no workspaces, and default settings, each row commented with its caller. A spec layers its own `RemoteTable` on top; a new boot-time call fails the spec at `dispose()`. + +`mock.remote` uses native `@vitest/spy.fn` functions shared by direct callers and Connection dispatch. `MockedRemote` applies Vitest's deep mock type transformation to the entire generated namespace map; an empty map weakens only this proxy to `any`. Production `Context` and Remote declarations remain strict, with no namespace-specific type copies or compiler flags. The [proxy typing guidance](../../../../packages/test-support/remote-mock/README.md#remote-proxy) requires build-backed local type checking even when unbuilt tests pass. + +## Product exports added for the tier + +- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional. +- `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed. +- `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier. +- `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them. +- `api/gateway`: `carrierFailure` and `cancelledFailure` are exported so a stand-in for the generated client folds identically. + +## Alternatives considered + +**Running the generated `/remote` clients from built `lib/`.** Rejected: it makes source-plane specs depend on a build artifact, and the proxies need only the unary-or-stream declaration the mock already holds. + +**A generated static roster module with a drift gate.** Rejected after review: it is a copy of bundle data inside the test package, and every subset written against it is a hand-list that misses rows. Reading the bundles at import through the include plugin's own schema and patch application removes the copy, the generator, and the gate. + +**A Host compile face for the test runtime, a dynamic import of a Host module, or a vitest `globalSetup` handing rosters through `provide`/`inject`.** Rejected: a Client test runtime must not import Host code, dynamic imports hide the dependency, and a config-level channel hides the roster's source. The composition functions the launcher uses are face-neutral, so none of these is needed. + +**A hand-written test-side YAML and patch parser.** Rejected: `entryListSchema` and `applyEntryPatches` are the launcher's own and carry no Host Context merge; the tier writes only file reading, package.json location, and the web-row filter. + +**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; installing it on the Connection carrier keeps retry, folding, and stream semantics real. + +**A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime. + +**Proxies passing unary rejections through unchanged.** Rejected: product code never awaits a Remote rejection because the generated client folds carrier throws, so an unmatched endpoint produced unhandled rejections; folding through the exported helpers restores the client's face. + +**Keeping CallContext and wrapping native spies in an adapter.** Rejected: it makes each test unwrap a synthetic call and retains counter/state machinery with no business-spec consumer. Positional handlers use the existing test ecosystem directly. + +**A separate `once` / `sequence` DSL and fallback rule stack.** Rejected: native per-instance queues already express the deferred responses and temporary failures used by consumers. Immutable table declarations with a cursor per registration would allow shared one-shot tables, but no current shared table requires them. The tier gives up newest-registration-first fallback and table-level repeat-last declarations; tests use native queue order and a persistent default instead. Response values and stateful handlers remain borrowed, not cloned. + +**A separate pre-materialized-module option or `staticModules`.** Rejected: the existing pending registration queue accepts the same factories before Loader startup. `staticModules` bypasses factory materialization and does not share graph prefetch/invalidation behavior; the queue removes the extra option without losing that behavior. Assembly-only helpers remain in their leaf modules rather than the recommended entry's exports. + +**A compiler-wide fallback flag or private augmentation package.** Rejected: declaration merging affects every file in a TypeScript Program that reaches the import; `private: true` only prevents publication. Separate test compiler graphs and additional policy checks add configuration maintenance without narrowing the fallback to its actual helper consumers. A local conditional type limits weakened inference to those consumers. + +**Per-namespace helpers with selected method lists and separate spy aliases.** Rejected: they repeat operation names and controls already supplied by native mocks. The generic proxy derives every method from the production namespace map, while fixtures own the returned data rather than a second implementation of domain writes or publication. + +## Consequences + +Specs boot real plugins: the whole `web` roster costs about five seconds cold and well under a second warm, and a three-row cone about twenty milliseconds per test. Assertions read product facts — the real section list, the real declarer, a Loader rebuild, a second `$events` generation on reconnect — and change when the product changes. + +The proxies skip the generated client's zod validation, wire-name mapping, and scoped-identity injection; mock rules read positional `args`, and the generated clients stay covered by the built-artifact e2e lanes. `remoteDefaultResponses` must gain a row when a plugin adds a boot-time call, and fails loud until it does. The two bundle names of the `web` profile are repeated once in `WEB_PROFILE_BUNDLES`, mirroring the launcher's `PROFILE_TEMPLATES.web`, and no check links the two: the client test program cannot import `@deepseek-ai/dsh-app-boot`, whose Host `Context` merges collide with the Client ones, and the test runtime takes no Host dependency even for tests. A template change therefore has to be carried to that constant by hand. + +The shared functions keep production and test callers on one implementation. `AppWebEntry.run()` mounts the Loader after the immediate-tier prefetch settles; application entry creation remains after prefetch, so serializing Loader setup with prefetch does not advance application activation. + +Native stream overrides may return their own iterable. The caller then owns consumption and cancellation; these iterables bypass scripted-stream logs and controls. Registered scripts retain managed queues and cancellation. This distinction preserves native mock behavior without adding another iterator wrapper or changing pull timing. + +## Deferred + +Product facts the tier surfaced and leaves as they are: + +- No `declare module` augmentation declares `Context.connection`; consumers read `ctx.get('connection') as ConnectionHandle`, and `TestClient.connection` is the typed entry the tier offers. +- `TestClient.start` has no page-URL option, so a spec that needs the `connection` plugin to classify the page as off-loopback reconfigures the jsdom instance vitest exposes on `globalThis.jsdom`, a private detail of the jsdom environment provider. +- `ISessions` exposes no queue observation point, so queue frames reach a `Session` through `handleControlFrame` directly rather than over the `session/control` stream. +- A durable event pushed twice with the same seq is dropped at the tail of `RemoteJournalStream` as a replay and never reaches `SessionQueueMirror.acceptDurable`. +- The vendored Loader rejects `create()` when a module import fails, so the import-failed branch of `assertEntriesActive` is unreachable through `create()`. +- The session-controller client casts `ctx.remote as unknown as SessionRemotes`; in the client test program the cast is redundant, since the generated `/remote` merges are visible there. + +## Testing + +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md new file mode 100644 index 0000000000..8b4381dbfa --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -0,0 +1,90 @@ +# Agent Note: 基于端点具名 Remote mock 的整机客户端测试档 + +Status: implemented + +[English](2026-09-06-client-assembly-test-line.md) | 中文 + +## 问题 + +浏览器功能 spec 各自手拼测试台:一个裸 Cordis context、`locale`、`connection`、`remote` 的替身,以及本该由真声明者做出的 slot 声明。它们的断言因此描述的是测试台而不是产品:一个插件新增了设置 section、一个声明者经 Loader 重载、一个 Connection 重连,对它们都不可见,而每个测试台都重复着同样的四十行并各有细小漂移。 + +API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个假件重新实现了它本该只是调用的 Gateway 语义:从历史列表推导 follow 流的开场快照、切页、带投递 promise 的流泵,以及一层信封。每一样都是产品已有契约的第二份实现,它还让测试描述出生成客户端做不出来的行为,比如一次会 reject 的一元调用。 + +没有聚焦源码测试按生产方式起客户端——`bootClient` 按 manifest 每行建一个 Loader entry,再 `mountClient`——插件之间的组合故障会被手工测试台遮住。 + +## 决定 + +整机档放在 `@deepseek-ai/dsh-client-test-runtime` 的深 import `src/assembly/` 下,新的 test-support 包 `@deepseek-ai/dsh-remote-mock` 按端点名应答 Remote 流量。两者的用法由各自 README 描述([client-runtime](../../../../packages/test-support/client-runtime/README.zh.md)、[remote-mock](../../../../packages/test-support/remote-mock/README.zh.md));本文记录它们背后的决定。 + +**roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。 + +**生产启动路径原样运行。** `TestClient.start(plan, mock)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。 + +**`remote.` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote..(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `/`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.` 解析到服务 `remote.`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。 + +**原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote..` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。 + +**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。 + +**`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。 + +`mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。 + +## 为本档新增的产品导出 + +- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。 +- `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。 +- `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。 +- `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。 +- `api/gateway`:导出 `carrierFailure` 与 `cancelledFailure`,让生成客户端的替身折叠得一模一样。 + +## 考虑过的替代方案 + +**从构建后的 `lib/` 运行生成的 `/remote` 客户端。** 否决:它让源码面的 spec 依赖构建产物,而代理只需要 mock 已持有的一元或流声明。 + +**带漂移门禁的生成静态 roster 模块。** 评审后否决:它是测试包内的一份 bundle 数据拷贝,基于它写的每个子集都是会漏行的手列清单。用 include 插件自己的 schema 与补丁应用在 import 时读 bundle,把拷贝、生成器和门禁一起去掉。 + +**给测试运行时加 Host 编译面、动态 import 一个 Host 模块,或用 vitest `globalSetup` 经 `provide`/`inject` 传 roster。** 否决:Client 测试运行时不得 import Host 代码,动态 import 藏起依赖,配置层通道藏起 roster 的来源。启动器用的合成函数本就面中立,这些都不需要。 + +**自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。 + +**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。 + +**第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。 + +**代理把一元拒绝原样直传。** 否决:产品代码从不等待 Remote 的 reject,因为生成客户端会折叠载体抛错,于是没匹配的端点造成未处理的拒绝;经导出的辅助函数折叠恢复了客户端的面。 + +**保留 CallContext,再用适配器包装原生 spy。** 否决:每个测试都要解开合成调用对象,还保留没有业务 spec 消费者的计数/状态机制。位置参数 handler 直接使用现有测试生态。 + +**独立的 `once` / `sequence` DSL 与回退规则栈。** 否决:按实例持有的原生队列已经能表达消费方使用的延迟响应和临时失败。不可变表声明配合每次登记的游标虽能共享一次性响应表,但当前没有共享表需要它。本档放弃最新登记优先的回退和表级末项重复声明,测试改用原生队列顺序与持续默认响应。响应值和有状态 handler 仍按引用借用,不做克隆。 + +**独立的预加载模块选项或 `staticModules`。** 否决:现有待注册队列能在 Loader 启动前接收同样的工厂。`staticModules` 绕过工厂物化,也不共享图的预取/失效行为;队列在保留这些行为的同时省掉额外选项。仅装配内部使用的 helper 保留在叶模块,不从推荐入口再导出。 + +**编译器级全局降级 Flag 或私有类型增补包。** 否决:声明合并会影响同一 TypeScript Program 中能够到达该导入的所有文件;`private: true` 只阻止发布。拆分测试编译图或增加策略检查会增加配置维护成本,却不能把降级限制在真正使用它的 Helper 中。局部条件类型只弱化这些消费方的类型推断。 + +**为每个命名空间编写方法清单和独立 spy 别名的 Helper。** 否决:它们重复操作名称和原生 mock 已经提供的控制功能。通用 Proxy 从生产命名空间映射派生每个方法,fixture 拥有返回数据,而不再实现另一份领域写入或发布机制。 + +## 后果 + +spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远低于一秒,三行的锥每例约二十毫秒。断言读的是产品事实——真实的 section 清单、真实的声明者、一次 Loader 重建、重连时的第二代 `$events`——并随产品变化而变化。 + +代理跳过了生成客户端的 zod 校验、wire 字段名映射与 scoped 身份注入;mock 规则读位置 `args`,生成客户端仍由构建产物 e2e 车道覆盖。插件新增启动期调用时 `remoteDefaultResponses` 必须加一行,加之前会响亮失败。`web` profile 的两个 bundle 名在 `WEB_PROFILE_BUNDLES` 里重复了一次,对应启动器的 `PROFILE_TEMPLATES.web`,且两者之间没有机检联系:客户端测试程序不能 import `@deepseek-ai/dsh-app-boot`(其 Host `Context` 合并与 Client 的冲突),而测试运行时连测试也不引入 Host 依赖。模板变更因此要靠人工带到这个常量。 + +共享函数让生产和测试调用方使用同一份实现。`AppWebEntry.run()` 在立即层预取落定后挂载 Loader;应用 entry 仍在预取之后创建,因此让 Loader 安装与预取串行不会提前应用激活。 + +原生流覆盖可以返回自有 iterable,此时调用方负责消费与取消,这些 iterable 不参与脚本流日志或控制。已登记的脚本仍使用受控队列与取消机制。这一区分保留原生 mock 行为,无需再添加 iterator 包装器或改变拉取时机。 + +## 遗留事项 + +本档暴露出来、原样保留的产品事实: + +- 没有任何 `declare module` 增强声明 `Context.connection`;消费方一律 `ctx.get('connection') as ConnectionHandle`,`TestClient.connection` 是本档提供的带类型入口。 +- `TestClient.start` 没有页面 URL 选项,需要 `connection` 插件把页面判为 off-loopback 的 spec 只能重配 vitest 挂在 `globalThis.jsdom` 上的 jsdom 实例,这是 jsdom 环境提供者的私有细节。 +- `ISessions` 没有 queue 观察点,queue 帧只能直接经 `handleControlFrame` 到达 `Session`,而不是走 `session/control` 流。 +- 同 seq 的 durable 事件二次推送在 `RemoteJournalStream` 尾部被当作重放丢弃,到不了 `SessionQueueMirror.acceptDurable`。 +- vendored Loader 在模块 import 失败时让 `create()` reject,因此 `assertEntriesActive` 的 import 失败分支经 `create()` 不可达。 +- session-controller 客户端把 `ctx.remote` cast 成 `SessionRemotes`;在客户端测试程序里这个 cast 是多余的,因为生成的 `/remote` 合并在那里可见。 + +## 测试 + +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 663762c55d..c07d097e40 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -31,6 +31,7 @@ There is no universal local baseline beyond the hooks. Every behavior change nee When the outgoing change adds or changes a resource-owning or asynchronous test, fixture, helper, or CI execution path, use [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) first to decide whether restoration, negative-control, quiescent-teardown, or concurrent-process evidence applies. This skill still selects the commands and avoids repeating evidence that already passed. - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. +- **Remote mock typing:** unbuilt `any` is an explicit local fallback, not strict evidence. Run `pnpm run typecheck` before handing off Remote/mock changes; rebuild missing, stale, or partial generated declarations before diagnosing remaining errors. Keep the exception in the [test proxy](../../../packages/test-support/remote-mock/README.md#remote-proxy), never in production Remote types, ambient flags, or copied signatures. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 088340c275..a6748b33bf 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: f33b351fea170124bb1ebc2b3fdf18be7539c65d -config-catalog.zh.md: 7d69c23e7fed3584a320cee1746ffcf660040193 +config-catalog.md: 9c774217aca68a1ac1dfce73cebb666af3781729 +config-catalog.zh.md: bba70aad7fab71f44e152b61bfb3873f81a543f9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f33b351fea..9c774217ac 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3579,6 +3579,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts)) - `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts)) +- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts)) - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7d69c23e7f..bba70aad7f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3580,6 +3580,7 @@ export interface Config { - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts)) - `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts)) +- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts)) - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts)) - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index e6eaa903f2..409b921f38 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: cbaa8cb04a7ec2406b1f6e7f2c1dbf0a8dff0527 -module-graph.zh.md: 148b2730fccc1740d1c2f24d766f2aa7213093cf +module-graph.md: 9e1f873b5690838c8a680ddf4ee3dd9c009cec80 +module-graph.zh.md: 81abb4bb4959ad72901bfda270e08dccd237c256 diff --git a/docs/module-graph.md b/docs/module-graph.md index cbaa8cb04a..9e1f873b56 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -353,6 +353,7 @@ flowchart TD pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] + pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] end subgraph group_typert["packages/typert"] @@ -1156,10 +1157,13 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller pkg_client_test_runtime --> pkg_attachment pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_hmr + pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat pkg_client_test_runtime --> pkg_client_ui_conversation @@ -1167,6 +1171,8 @@ flowchart TD pkg_client_test_runtime --> pkg_client_ui_session pkg_client_test_runtime --> pkg_client_ui_settings pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web + pkg_client_test_runtime --> pkg_remote_mock pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent pkg_client_test_runtime --> pkg_typert_protocol @@ -1274,6 +1280,7 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1445,5 +1452,5 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 148b2730fc..81abb4bb49 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -355,6 +355,7 @@ flowchart TD pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] + pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] end subgraph group_typert["packages/typert"] @@ -1158,10 +1159,13 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller pkg_client_test_runtime --> pkg_attachment pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_hmr + pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat pkg_client_test_runtime --> pkg_client_ui_conversation @@ -1169,6 +1173,8 @@ flowchart TD pkg_client_test_runtime --> pkg_client_ui_session pkg_client_test_runtime --> pkg_client_ui_settings pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web + pkg_client_test_runtime --> pkg_remote_mock pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent pkg_client_test_runtime --> pkg_typert_protocol @@ -1276,6 +1282,7 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1447,5 +1454,5 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index ab767193d1..4d79b9f4c9 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/gateway/README.md -README.md: a234189cfff09bae894de14dada607a357b398fd -README.zh.md: 7a6e66724724efd83173835095a409c527faccef +README.md: 561081c3a5de716e563eaa9764f387e329365504 +README.zh.md: 9588b194efc64d14a17a69323a14d6118d0e7d7e diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index a234189cff..561081c3a5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -43,7 +43,7 @@ Host composition can register one application event source through `registerRemo Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. -Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. +Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. `ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 7a6e667247..9588b194ef 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -43,7 +43,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source 每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。 +每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 `ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是存储——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。 diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 55c4867520..285dc93dbf 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/modules/README.md -README.md: dad3cd22b111de0d482d74ea4c696467d1a3eeb0 -README.zh.md: ef7a01dd1e2a9ed3074fcb7a768ee0e478975fa4 +README.md: 83ba5357cf84a12105518ed057fd120584b636cb +README.zh.md: 9e5c75838e52fda7d74b074b9c1a9dacef7932a8 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index dad3cd22b1..83ba5357cf 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -80,7 +80,7 @@ The host contributes structured index rows that inject, into ``: the `wind | [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows | | [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment | | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery | -| [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types and boot-manifest parsing | +| [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types, boot-manifest parsing, and the `dsh.client` declaration parser | diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index ef7a01dd1e..9e5c75838e 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -80,7 +80,7 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map | [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 | | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 | | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 | -| [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型与启动 manifest 解析 | +| [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型、启动清单解析与 `dsh.client` 声明解析器 | diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 394c689596..313c9860c2 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/web/README.md -README.md: 723a68ed401558021ca404b4a81e04c0d18d71be -README.zh.md: 4f6f8eb746ba8ad8715ba4bccf4c3573aa4be24b +README.md: 6bc0c4f241d1afb28825cadc19f89b7f54d4b637 +README.zh.md: e2d7ab81e82f1b5b1b93b95e4ed117a80f897a8e diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 723a68ed40..6bc0c4f241 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -72,7 +72,9 @@ The boot page is plain DOM with local CSS whose fallback fonts and colors match | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Library entry: `AppWebEntry`, `getStaticModules`, platform tables | -| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: two-stage boot, activation audit, renderer handoff | +| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: module stage, boot page, immediate-tier prefetch, then `bootClient` + `mountClient` | +| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`: Loader mount, one entry per manifest row, activation audit | +| [`src/mount.ts`](src/mount.ts) | `mountClient`: renderer handoff through a `uiRenderer` dependency fiber | | [`src/boot-page.ts`](src/boot-page.ts) | Framework-free boot page: spinner, per-entry status, failure rendering | | [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`: the implicit external baseline | | [`src/seed.ts`](src/seed.ts) | Static module table handed to the loader at boot | diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index 4f6f8eb746..e2d7ab81e8 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -72,7 +72,9 @@ kind: "package-library" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 库入口:`AppWebEntry`、`getStaticModules`、平台表 | -| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:两阶段启动、激活审计、渲染器交接 | +| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:模块阶段、启动页、immediately 层级预取,随后调用 `bootClient` + `mountClient` | +| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`:挂载 Loader、每个 manifest 行一个 entry、激活审计 | +| [`src/mount.ts`](src/mount.ts) | `mountClient`:经 `uiRenderer` 依赖 fiber 完成渲染器交接 | | [`src/boot-page.ts`](src/boot-page.ts) | 无框架启动页:spinner、逐 entry 状态、失败渲染 | | [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`:隐式 external 基座 | | [`src/seed.ts`](src/seed.ts) | 启动时交给 loader 的静态模块表 | diff --git a/packages/test-support/README.i18n.yaml b/packages/test-support/README.i18n.yaml index d6c6102a5e..d123f288aa 100644 --- a/packages/test-support/README.i18n.yaml +++ b/packages/test-support/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/test-support/README.md -README.md: 787ebfeb16cd2b07f183cf5b4ab5adda8a95156b -README.zh.md: 3c98d99b71616e0e23140e736189f7b1768d2bcf +README.md: d3bd4bbff218227d4b0423f7f1287368e7f87a99 +README.zh.md: db451041f9a3cd3459aa8e591e10f1720e358557 diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 787ebfeb16..d3bd4bbff2 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -27,6 +27,7 @@ The test-support group gives repository tests deterministic, keyless ways to exe | [`session-snapshot`](session-snapshot/README.md) | Provides session-log snapshot support and protocol adapters for profile-driven tests | | [`agent-loop-testkit`](agent-loop-testkit/README.md) | Provides the shared prerequisite services for tests that exercise the concrete AgentLoop | | [`client-runtime`](client-runtime/README.md) | Provides the jsdom slot test bench for browser feature specs | +| [`remote-mock`](remote-mock/README.md) | Provides the endpoint-named Typert Remote mock and the Connection carrier face whole-client specs install | | [`loader-smoke`](loader-smoke/README.md) | Boots Loader-composed applications and drives fixture turns for smoke tests | | [`llm-mock-server`](llm-mock-server/README.md) | Provides a scriptable OpenAI-compatible fault server for recovery tests | | [`llm-replay`](llm-replay/README.md) | Replays recorded model streams for keyless tests and demos | diff --git a/packages/test-support/README.zh.md b/packages/test-support/README.zh.md index 3c98d99b71..db451041f9 100644 --- a/packages/test-support/README.zh.md +++ b/packages/test-support/README.zh.md @@ -27,6 +27,7 @@ test-support 组为仓库测试提供确定且无须密钥的真实产品测试 | [`session-snapshot`](session-snapshot/README.zh.md) | 为 profile 驱动的测试提供 session-log 快照支持与协议适配器 | | [`agent-loop-testkit`](agent-loop-testkit/README.zh.md) | 为运行具体 AgentLoop 的测试提供共享先决服务 | | [`client-runtime`](client-runtime/README.zh.md) | 为浏览器功能测试提供 jsdom slot 测试台 | +| [`remote-mock`](remote-mock/README.zh.md) | 为整体客户端测试提供端点具名的 Typert Remote mock 与它们安装的 Connection 载体面 | | [`loader-smoke`](loader-smoke/README.zh.md) | 启动由 Loader 组合的应用并驱动 fixture(测试前置数据)轮次以执行冒烟测试 | | [`llm-mock-server`](llm-mock-server/README.zh.md) | 为恢复测试提供可通过脚本控制的 OpenAI 兼容故障服务器 | | [`llm-replay`](llm-replay/README.zh.md) | 为无密钥测试与演示回放已记录的模型流 | diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 1fc388204f..74f763cef7 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-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/test-support/client-runtime/README.md -README.md: 0b6caf7237520ca1a10fe0bbdf3c12c6d793461f -README.zh.md: ce78a0ad623247ad769e0ea91895daa1db8e7f1b +README.md: 61426ccb8002032508feb3c9dbd26718d676177d +README.zh.md: e462f42c1d6ac7a3c7951e65ae23a424679057b0 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 0b6caf7237..61426ccb80 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-test-runtime` lets browser feature specs exercise production slot, store, rendering, update, and disposal behavior in jsdom without reimplementing the UI runtime. Test authors can publish typed Session, Workspace, projection, and Conversation fixtures, query slot-local DOM roots, and script Remote replies or failures. Missing services, unstubbed session behavior, and unexpected file uploads fail at the call site, while disposal is idempotent. Use it only from in-repository browser-oriented Vitest suites through `devDependencies`; it is not a product plugin or general Node test harness. +`SlotTestRuntime.create()` lets Vitest suites drive production slots, stores, typed Session and Workspace fixtures, and local DOM assertions in jsdom. For plugin activation, reload, reconnect, and cleanup tests, `createClientTest` starts the web profile's bundle roster with endpoint-named Remote mocks, without a business Host. Missing services and unstubbed calls fail explicitly. The whole-client fixture owns startup and disposal; the local runtime provides idempotent disposal. Use this package through `devDependencies` for client tests; it is not a product plugin. ## Table of Contents @@ -64,6 +64,33 @@ remote.goals.create.mockResolvedValue({ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ``` +### Whole-client tier + +The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` installs `{ rpc: mock.rpc }` as `globalThis.__DSH_TRANSPORT__`, imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it: + +```text +// @vitest-environment jsdom +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { ok } from '@deepseek-ai/dsh-remote-mock' + +const test = createClientTest({ roster: webApp }, { mount: true }) +test('registers into the sidebar', async ({ remote, start }) => { + remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] })) + const client = await start() + expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1) +}) +``` + +`createClientTest` uses native Vitest fixtures: each test gets a fresh `mock` loaded with `remoteDefaultResponses`, a `remote` proxy equal to `mock.remote`, and a `start()` that boots only after the test configures its responses. Repeated starts share one promise; await it to observe startup errors. Fixture teardown waits for startup, disposes the client even after a failed assertion, checks missing responses, and refuses a saved `start` after the test. Use `TestClient.start` directly for independently owned clients. These fixtures isolate their own state, not page globals such as `location`. + +The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every namespace in both tiers. An assembled test uses the `remote` fixture; a local `TestRemote` can receive `{ settings: mock.remote.settings }`. Configure returned data and read native `.mock.calls` directly. A mutation response does not update later describe answers automatically: change `remote.settings.describe.mockResolvedValue(...)` explicitly when the scenario publishes new data. The proxy documentation owns unbuilt typing and the required build-backed local typecheck. + +### Browser module loading + +A browser page imports `src/assembly/browser.ts`, supplies parsed `ClientRoster` rows, and passes `loadClientModule(name)` in `TestClient.start` options. The importer returns source module namespaces in that page's realm; `provide` replacements and the bootstrap module retain their priority. Node-side bundle reading and Vitest fixtures remain in `src/assembly/index.ts`. Function handlers, native mock queues, and promises are created in the realm where the mock runs, not sent through Playwright serialization. The [shared settings scenario](tests/assembly-settings-scenario.client.ts) runs in jsdom and in [two concurrent Chromium pages](../../../apps/web/tests/client-assembly.e2e.ts), using the web build's source aliases and definitions on independently allocated static servers without a business Host. Source assembly does not test published bundle loading or generated Remote clients. + +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. + ### When to use it Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is browser-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. @@ -99,6 +126,14 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy | +| [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`, `ClientRoster` (`of`/`closure`/`pick`/`without`), the `AssemblyPlan` it annotates, and `graphFromRoster` | +| [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue | +| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: transport install, jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | +| [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | Test-scoped `mock` and lazy `start` fixtures | +| [`src/assembly/browser.ts`](src/assembly/browser.ts) | Realm-local startup without bundle-file reading or Vitest fixtures | +| [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`: default responses of the Remote endpoints the roster calls at boot | +| [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | Contract-free `remote.` proxies over the Connection: `remoteNamespacesOf`, `remoteProxiesPlugin` | +| [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` and `webApp`: the browser roster read from bundle patch files with the include plugin's own schema and patch application | | — | No runtime invariant companion is published; this test-support package owns no production event stream or mutable data — it assembles the runtime SlotRegistry and renderer (whose packages own their invariants) around test doubles; its own behavior is exercised by its package tests. | ### Lifecycle @@ -138,7 +173,11 @@ None; this package neither assembles nor sends a provider request. These limits define how the bench is consumed. They are current package constraints, not a task backlog. -- **Vitest and jsdom only** — every consumer is an in-repository browser-oriented Vitest suite. The package is not a product plugin or a general Node test harness. +- **Separate runner entries** — the slot tier and native fixtures use Vitest; browser assembly imports `src/assembly/browser.ts` and supplies a source importer. Neither tier starts a Host or validates HTTP/WebSocket encoding. +- **The whole-client tier does not run the generated Remote clients** — `remote.` proxies forward positional arguments without the generated zod validation, wire-name mapping, or scoped-identity injection; mock handlers receive those arguments directly, and the generated clients stay covered by the built-artifact e2e lanes. +- **Proxied calls bypass the Gateway client's `invoke` and `invokeStream`** — no `$mount` lifecycle check runs, stream failures are not re-marked through `normalizeConnectionStream`, and a unary rejection is folded by the proxy itself with the Gateway client's exported `carrierFailure` and `cancelledFailure`. `ctx.remote.$stream`, `$on`, and `$host` are the real Gateway client's. +- **An undeclared endpoint is dispatched as a unary call** — the proxy learns each endpoint's mode from the mock's registrations; a stream endpoint the spec neither scripts nor declares (`RemoteTable.streams`, `mock.stream(endpoint)`) is logged as a `unary` miss and product code receives a folded result rather than a failing stream. `remoteDefaultResponses` declares the roster's later-opened streams; `dispose()` fails the test either way. +- **The package's client compile program adds the `node` ambient types** so the roster reader can use `node:fs`; the slot-tier sources compile against them as well. - **Session, Conversation, and Chat fixtures stay separate** — `sessionSnapshot` contains only Session Controller state, `conversationSnapshot` contains target-neutral Conversation state, and `chatSnapshot` contains Chat target state. Assembly tests provide Session event entries instead of adding Conversation or Chat fields to `SessionSnapshot`. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index ce78a0ad62..e462f42c1d 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-test-runtime` 让浏览器功能测试在 jsdom 中检验生产 slot、存储、渲染、更新与销毁行为,而无需重实现 UI 运行时。测试作者可以发布带类型的 Session、Workspace、projection 与 Conversation fixture(测试前置数据),查询 slot 局部 DOM 根,并脚本化 Remote 应答或失败。缺失服务、未打桩的会话行为与意外文件上传都会在调用点失败,销毁则保持幂等。仅限仓内、面向浏览器的 Vitest 套件通过 `devDependencies` 使用本包;它不是产品插件或通用 Node 测试 harness。 +`SlotTestRuntime.create()` 让 Vitest 套件在 jsdom 中驱动生产 slot、store、带类型的 Session 与 Workspace fixture,并对局部 DOM 断言。面向插件激活、重载、重连与清理的测试,`createClientTest` 使用具名端点 Remote mock 启动 web profile 的 bundle roster,无需业务 Host。缺失服务与未打桩调用会明确失败。整机 fixture 拥有启动和销毁,局部 runtime 提供幂等销毁。通过 `devDependencies` 将本包用于客户端测试;它不是产品插件。 ## 目录 @@ -64,6 +64,33 @@ remote.goals.create.mockResolvedValue({ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ``` +### 整体档 + +上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 把 `{ rpc: mock.rpc }` 装到 `globalThis.__DSH_TRANSPORT__`,进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它: + +```text +// @vitest-environment jsdom +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { ok } from '@deepseek-ai/dsh-remote-mock' + +const test = createClientTest({ roster: webApp }, { mount: true }) +test('registers into the sidebar', async ({ remote, start }) => { + remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] })) + const client = await start() + expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1) +}) +``` + +`createClientTest` 使用原生 Vitest fixture:每个测试获得已加载 `remoteDefaultResponses` 的新 `mock`、等同于 `mock.remote` 的 `remote` Proxy,以及配置应答后才起机的 `start()`。重复启动共用一个 Promise,调用方必须 await 它来观察启动错误。fixture 收尾等待启动,即使断言失败也销毁客户端、检查漏配,并拒绝测试结束后保存的 `start` 调用。需要分别拥有多个客户端时直接用 `TestClient.start`。这些 fixture 隔离自己的状态,不隔离 `location` 等页面全局。 + +两档测试的所有命名空间都使用[通用 Remote Proxy](../remote-mock/README.zh.md#remote-proxy)。装配测试使用 `remote` fixture;局部 `TestRemote` 可以接收 `{ settings: mock.remote.settings }`。直接配置返回数据,并读取原生 `.mock.calls`。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 `remote.settings.describe.mockResolvedValue(...)`。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。 + +### 浏览器模块加载 + +浏览器页面导入 `src/assembly/browser.ts`,提供已解析的 `ClientRoster` 行,并在 `TestClient.start` 选项中传入 `loadClientModule(name)`。importer 在该页面 realm 内返回源码模块命名空间;`provide` 替换和 bootstrap 模块仍有原来的优先级。Node 侧 bundle 读取与 Vitest fixture 留在 `src/assembly/index.ts`。函数 handler、原生 mock 队列和 promise 在 mock 所在 realm 创建,不经过 Playwright 序列化。[共享 settings 场景](tests/assembly-settings-scenario.client.ts) 同时在 jsdom 和[两个并发 Chromium 页面](../../../apps/web/tests/client-assembly.e2e.ts)中运行,独立分配的静态服务器使用 web 构建的源码别名与定义,不启动业务 Host。源码装配不验证已发布 bundle 加载或生成的 Remote 客户端。 + +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 + ### 何时使用 当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是浏览器侧测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 @@ -99,7 +126,15 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | -| — | 不发布运行时不变式伴生入口;本测试支持包不拥有任何生产事件流或可变数据;它围绕测试替身组装运行时 SlotRegistry 与渲染器,其所属包拥有各自的不变式;本包自身行为由本包测试检验。 | +| [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`、`ClientRoster`(`of`/`closure`/`pick`/`without`)、它所标注的 `AssemblyPlan`,以及 `graphFromRoster` | +| [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 | +| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:装传输、jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | +| [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | 测试级 `mock` 与懒启动 `start` fixture | +| [`src/assembly/browser.ts`](src/assembly/browser.ts) | 不读取 bundle 文件、不引入 Vitest fixture 的 realm 内启动入口 | +| [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`:roster 启动期 Remote 端点的默认响应 | +| [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | 经 Connection 的无契约 `remote.` 代理:`remoteNamespacesOf`、`remoteProxiesPlugin` | +| [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` 与 `webApp`:用 include 插件自己的 schema 与补丁应用从 bundle 补丁文件读出浏览器 roster | +| — | 不发布运行时不变式伴生入口;本测试支持包不拥有生产事件流或可变数据,而是围绕测试替身组装生产 SlotRegistry 与渲染器。所挂载的生产包拥有各自的不变式,本包行为由本包测试检验。 | ### 生命周期 @@ -138,7 +173,11 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') 这些限制说明本测试台如何被消费。它们是当前包约束,不是任务积压。 -- **仅限 Vitest 与 jsdom**——所有消费方都是仓内、面向浏览器的 Vitest 套件。本包不是产品插件,也不是通用 Node 测试 harness。 +- **runner 入口分开**——slot 档与原生 fixture 使用 Vitest;浏览器装配导入 `src/assembly/browser.ts` 并提供源码 importer。两档都不启动 Host,也不验证 HTTP/WebSocket 编码。 +- **整体档不运行生成的 Remote 客户端**——`remote.` 代理转发位置参数,不经过生成的 zod 校验、wire 名映射或 scoped 身份注入;mock handler 直接接收这些参数,生成客户端仍由 built-artifact e2e 车道覆盖。 +- **代理调用绕过 Gateway 客户端的 `invoke` 与 `invokeStream`**——不做 `$mount` 生命周期检查,流失败不经 `normalizeConnectionStream` 重新标记,一元拒绝由代理自己用 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure` 折叠。`ctx.remote.$stream`、`$on`、`$host` 是真 Gateway 客户端的。 +- **未声明的端点按一元调用发出**——代理从 mock 的登记学到每个端点的模式;spec 既没给脚本也没声明(`RemoteTable.streams`、`mock.stream(endpoint)`)的流端点记为 `unary` 漏配,产品代码收到的是折叠结果而不是失败的流。`remoteDefaultResponses` 声明了 roster 启动后才打开的流;无论哪种,`dispose()` 都会让测试失败。 +- **本包的 client 编译程序加了 `node` 环境类型**,好让 roster 读取器使用 `node:fs`;slot 档的源码也在这些类型下编译。 - **Session、Conversation 与 Chat fixture 保持分离**——`sessionSnapshot` 只包含 Session 控制器状态,`conversationSnapshot` 包含与目标无关的 Conversation 状态,`chatSnapshot` 包含 Chat 目标状态。组装测试提供 Session 事件条目,而不是向 `SessionSnapshot` 添加 Conversation 或 Chat 字段。 diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml new file mode 100644 index 0000000000..048e61ea36 --- /dev/null +++ b/packages/test-support/remote-mock/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/test-support/remote-mock/README.md +README.md: 48cfeec7c6a49c6bd7017db27d2feda4052858ad +README.zh.md: ca230762467f0e702622cd5dcbc24e2552679d58 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md new file mode 100644 index 0000000000..48cfeec7c6 --- /dev/null +++ b/packages/test-support/remote-mock/README.md @@ -0,0 +1,146 @@ +--- +description: "Endpoint-named mock for Typert Remote traffic: a table of unary answers and stream scripts, live stream control, a log, and the Connection carrier face, for test authors booting a real browser client without a Host." +kind: "package-library" +--- + +# @deepseek-ai/dsh-remote-mock + +English | [中文](README.zh.md) + +## Summary + +`dsh-remote-mock` lets tests configure Host responses through `mock.remote..` using native Vitest mock methods. The same functions answer direct calls and real Connection traffic; reusable tables supply default responses, and explicitly declared streams support test-driven frames and cancellation. Missing responses fail the call and are reported again by `assertNoUnmatched()` at teardown. The package runs without a business Host in Node or a browser page, imports no DOM, React, or Node modules, and is consumed from `devDependencies` only. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +### When to use it + +Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client jsdom specs through `__DSH_TRANSPORT__`, and unit specs that call `dispatch` / `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`). + + +### Use the Remote proxy + +`mock.remote` exposes every namespace and method without a method list or a domain-specific helper. Each accessed endpoint uses a cached native Vitest mock; `@vitest/spy.fn` is the implementation behind `vi.fn` and also works in a browser page without the Vitest runner. The same mock answers direct calls and Connection traffic, so return overrides and call assertions observe the functions the client actually invokes: + +```text +const mock = RemoteMock.create().load(remoteDefaultResponses) +mock.remote.settings.describe.mockResolvedValue(ok({ + writable: true, hasDocument: false, namespaces: [], +})) +mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace)) +// After the client writes: +expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision) +``` + +Use `mockResolvedValue` for a persistent response, `mockResolvedValueOnce` or `mockReturnValueOnce` for queued responses, and `mockImplementation` for argument-dependent behavior. Native queued responses run in registration order, then the mock's current implementation answers. Its initial implementation reads the registered default. `mockClear()` preserves responses and queues; `mockReset()` clears overrides and restores that initial implementation, which reads the latest default. Missing defaults still fail after queued responses are exhausted, including direct calls, which can throw synchronously. + +Only explicit `stream()` or table stream declarations select stream methods; everything else uses unary mocks. Accessing a method does not fabricate a successful business result. Declare stream mode before retaining a method reference: each endpoint/mode owns its own mock. Namespace and method `then` probes and symbol reads are inert. + +`MockedRemote` uses Vitest's deep mock transformation over the entire generated `TypertRemoteNamespaceMap`. A non-empty map preserves its namespace and method names, parameters, results, and native spy types. An empty map makes only this test proxy `any`, allowing arbitrary namespaces and methods; it does not augment or weaken production Remote declarations. No copied method signatures, optional generated-module suppressions, or compiler-wide flags are needed. Before handing off Remote/mock changes, run `pnpm run typecheck` to generate and check the real Client types; missing, stale, or partial declarations require rebuilding first. Unbuilt test success or `any` inference is not strict type evidence. + +### Register default responses + +`load(table)` installs reusable `unary` values or handlers, `stream` scripts, and scriptless `streams` declarations. `unary(endpoint, value)` and `unary(endpoint, fn)` register individual defaults; handlers receive the caller's positional arguments and use existing request types. Each endpoint holds only its latest default, including an explicit `undefined`; updating defaults does not discard native overrides. `ok(value)` builds `{ ok: true, value }`; failures use `{ ok: false, error: { code, message, details } }`. Keep stateful handlers, promises, and native queues local to each test: + +```text +const initial = { writable: true, hasDocument: false, namespaces: [] } +const mock = RemoteMock.create().load({ + unary: { 'settings/describe': ok(initial) }, +}) +mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true })) +``` + +### Drive streams + +A stream script is a function of the open `args` and a `StreamHandle` (`push`, `end`, `fail(error)`, `signal`); the stream stays open after the script returns until the handle ends or fails it. `frames(items)` builds a script that yields the items and ends; `openStream(initial)` one that yields them and stays open. `mock.streams` controls streams the client currently holds open, filtered by the args they were opened with; `opened(endpoint, count)` resolves once the endpoint has been opened that many times, and `drained(endpoint)` once the consumer of every matching stream has pulled everything pushed so far, an open stream's consumer waiting for more and a settled stream's queue empty (pulled from the queue; that equals processed only for a consumer that handles items inside its read loop): + +```text +mock.stream('session/follow', openStream([snapshotFrame])) +await mock.streams.opened('session/follow', 1) +mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID) +mock.streams.fail('session/follow', new Error('gone')) +await mock.streams.drained('session/follow') +``` + +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the open `signal`, or an early `return`) ends the iteration without throwing and logs the stream as `cancelled`. + +### Connect a client + +`mock.rpc` is the `ClientConnectionRpc` face: install it as `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }` and the production `connection` plugin uses it in place of the HTTP caller, so every Remote call reaches `dispatch` and every stream `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream. + +### Observe and assert + +`mock.log.calls(endpoint?)` lists unary calls through `dispatch` or `rpc.call` (`args`, `seq`, a live `state` of `pending` / `answered` / `failed`, and the answer or thrown error as `result`), `streams(endpoint?)` lists scripted opens with their live `state` and `pushed` count, `requests(endpoint?)` lists the first positional arg of calls and opens in order (without an endpoint, excluding the Gateway's own `$`-prefixed endpoints), and `unmatched()` lists requests that found no rule. Native `.mock.calls` additionally includes direct proxy calls; carrier stream mocks receive their final cancellation signal. `assertNoUnmatched()` reports misses at teardown. `modeOf(endpoint)` reports explicit registrations; `endpoints()` also includes accessed proxy methods so assembly can provide their namespaces. + +### What can go wrong + +- **A request has no rule** — `dispatch` rejects and `open` throws `remote-mock: no rule for ; registered: …`, and the log records the miss; register the endpoint. +- **A payload is not `{ args: unknown[] | object }`** — `rpc.call` rejects and `rpc.open` throws a `TypeError`; the whole-client proxies send the array form and the Gateway's own endpoints the object form, so a hand-written call is at fault. +- **A second concurrent read on one stream** — the read rejects; the Gateway reads streams sequentially, so this names a test-side misuse. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +### Design + +`dispatch` and `open` take the endpoint and positional args; `rpc` exposes the same core through Connection's decoded carrier interface. Each mock owns its native functions and queued overrides; shared tables supply defaults without copying handlers or answer objects. Each scripted stream owns its queue, its single pending read, and its log entry; the first of `end`, `fail`, or consumer cancellation settles it. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Public face re-exports | +| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`: default responses, native mocks, Connection dispatch, controlled streams and missing-response checks; `ok` | +| [`src/remote-proxy.ts`](src/remote-proxy.ts) | Namespace/method lookup and generated-map mock types | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts and `MockStream` (handle + `AsyncIterable`) | +| [`src/log.ts`](src/log.ts) | Log store with the shared `seq` counter | +| — | No runtime invariant companion is published; this test-support library owns no production event stream or mutable process state, and its behavior is exercised by its package tests. | + +
+ +----- + + +## Model Experience + +None, as this package is browser-side test infrastructure; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **In-process carrier only** — `rpc` serves a client in the same realm through `__DSH_TRANSPORT__.rpc`; no HTTP or WebSocket carrier for browser-lane specs is provided. +- **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged. +- **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields. +- **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler. +- **Native stream overrides own their iterable** — an override returning its own iterable bypasses scripted-stream logs, `requests`, `opened`, `drained`, and `push` / `end` / `fail`; the caller also owns cancellation. Native call assertions still work. Use a registered stream script when a scenario needs those controls. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md new file mode 100644 index 0000000000..ca23076246 --- /dev/null +++ b/packages/test-support/remote-mock/README.zh.md @@ -0,0 +1,146 @@ +--- +description: "Typert Remote 流量的端点具名 mock:一元应答与流脚本的表、活流控制、日志与 Connection 载体面,供测试作者在没有 Host 的情况下启动真实浏览器客户端。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-remote-mock + +[English](README.md) | 中文 + +## 概述 + +`dsh-remote-mock` 让测试通过 `mock.remote..`,使用原生 Vitest mock 方法配置 Host 响应。同一组函数应答直接调用与真实 Connection 流量;可复用的表提供默认响应,显式声明的流支持测试驱动的推帧与取消。缺少响应时调用失败,`assertNoUnmatched()` 会在收尾时再次报告。本包无需业务 Host 即可在 Node 或浏览器页面中运行,不导入 DOM、React 或 Node 模块,只从 `devDependencies` 消费。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +### 何时使用 + +当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:经 `__DSH_TRANSPORT__` 的整体 jsdom 测试,以及直接调用 `dispatch` / `open` 的单元测试。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。 + + +### 使用 Remote Proxy + +`mock.remote` 无需方法清单或领域专属 Helper 即可提供每个命名空间和方法。每个访问过的端点使用缓存的原生 Vitest mock;`@vitest/spy.fn` 就是 `vi.fn` 背后的实现,也能在没有 Vitest runner 的浏览器页面中运行。同一个 mock 应答直接调用与 Connection 流量,因此返回值覆盖和调用断言观察的是客户端实际调用的函数: + +```text +const mock = RemoteMock.create().load(remoteDefaultResponses) +mock.remote.settings.describe.mockResolvedValue(ok({ + writable: true, hasDocument: false, namespaces: [], +})) +mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace)) +// After the client writes: +expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision) +``` + +使用 `mockResolvedValue` 设置持续响应,使用 `mockResolvedValueOnce` 或 `mockReturnValueOnce` 排队设置响应,使用 `mockImplementation` 按参数决定行为。原生队列按登记顺序消费响应,耗尽后由 mock 的当前实现应答;初始实现读取已登记的默认响应。`mockClear()` 保留响应与队列;`mockReset()` 清除覆盖并恢复初始实现,由它读取最新默认响应。若未配置默认响应,排队响应耗尽后仍会失败,包括可能同步抛错的直接调用。 + +只有显式 `stream()` 或响应表中的流声明才选择流方法;其余均使用一元 mock。访问方法不会凭空构造成功的业务结果。保存方法引用前先声明流模式:每个端点/模式拥有各自的 mock。命名空间和方法的 `then` 探测及 symbol 读取均无副作用。 + +`MockedRemote` 使用 Vitest 的深层 mock 类型转换,覆盖完整生成的 `TypertRemoteNamespaceMap`。非空映射保留命名空间与方法名、参数、返回值和原生 spy 类型。映射为空时只有这个测试 Proxy 变成 `any`,允许任意命名空间和方法;它不增补或弱化生产 Remote 声明。不需要复制方法签名、抑制可选生成模块错误或开启编译器级全局 Flag。交付 Remote/mock 改动前运行 `pnpm run typecheck`,生成并检查真实 Client 类型;声明缺失、陈旧或不完整时先重新构建。无构建测试通过或推断为 `any` 都不是严格类型证据。 + +### 登记默认响应 + +`load(table)` 安装可复用的 `unary` 值或 handler、`stream` 脚本以及无脚本的 `streams` 声明。`unary(endpoint, value)` 与 `unary(endpoint, fn)` 登记单个默认响应;handler 接收调用方的位置参数,并使用现有请求类型。每个端点仅保存最新默认响应,包括显式 `undefined`;更新默认响应不会清除原生覆盖。`ok(value)` 构造 `{ ok: true, value }`;失败使用 `{ ok: false, error: { code, message, details } }`。有状态 handler、promise 与原生队列均由各测试独立持有: + +```text +const initial = { writable: true, hasDocument: false, namespaces: [] } +const mock = RemoteMock.create().load({ + unary: { 'settings/describe': ok(initial) }, +}) +mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true })) +``` + +### 驾驭流 + +流脚本是一个接收打开时的 `args` 与 `StreamHandle`(`push`、`end`、`fail(error)`、`signal`)的函数;脚本返回后流保持打开,直到句柄结束或失败。`frames(items)` 构造吐完即结束的脚本,`openStream(initial)` 构造吐完后保持打开的脚本。`mock.streams` 控制客户端当前打开着的流,可按打开时的参数过滤;`opened(endpoint, count)` 在该端点被打开达到该次数时 resolve,`drained(endpoint)` 在每条匹配流的消费方都拉完了迄今推入的全部内容时 resolve——打开的流要其消费方再次等待,已定局的流要队列已空(拉完指从队列取走;只有在读循环内处理项的消费方才等于处理完): + +```text +mock.stream('session/follow', openStream([snapshotFrame])) +await mock.streams.opened('session/follow', 1) +mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID) +mock.streams.fail('session/follow', new Error('gone')) +await mock.streams.drained('session/follow') +``` + +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 `signal`,或提前 `return`)结束迭代而不抛错,并把该流记为 `cancelled`。 + +### 接上客户端 + +`mock.rpc` 是 `ClientConnectionRpc` 面:装成 `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }`,生产的 `connection` 插件就用它替代 HTTP 调用方,每次 Remote 调用直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。 + +### 观察与断言 + +`mock.log.calls(endpoint?)` 列出经 `dispatch` 或 `rpc.call` 的一元调用(`args`、`seq`、实时 `state` 为 `pending` / `answered` / `failed`,以及作为 `result` 的应答值或抛出的错误),`streams(endpoint?)` 列出脚本流的打开记录及其实时 `state` 与 `pushed` 计数,`requests(endpoint?)` 按顺序列出调用与打开的首个位置参数(不带端点时去掉 Gateway 自己带 `$` 前缀的端点),`unmatched()` 列出没找到规则的请求。原生 `.mock.calls` 还包含直接 Proxy 调用;载体的流 mock 会收到末尾的取消信号。`assertNoUnmatched()` 在收尾时报告漏配。`modeOf(endpoint)` 报告显式登记;`endpoints()` 还包含访问过的 Proxy 方法,使装配能够提供它们的命名空间。 + +### 可能出什么问题 + +- **请求没有规则**——`dispatch` reject、`open` 抛出 `remote-mock: no rule for ; registered: …`,日志记下这次漏配;请登记该端点。 +- **payload 不是 `{ args: unknown[] | object }`**——`rpc.call` reject、`rpc.open` 抛 `TypeError`;整机代理发数组形式、Gateway 自身端点发对象形式,所以问题出在手写调用。 +- **同一条流上有第二个并发读取**——该读取 reject;Gateway 顺序读取流,因此这指向测试侧误用。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +### 设计 + +`dispatch` 与 `open` 接收端点与位置参数;`rpc` 通过 Connection 的已解码载体接口暴露同一个核心。每个 mock 独立持有原生函数及排队覆盖;共享表提供默认响应,不复制 handler 或应答对象。每条脚本流拥有自己的队列、唯一挂起读取和日志条目;`end`、`fail`、消费方取消三者中最先发生者定局。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 公开面转出 | +| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`:默认响应、原生 mock、Connection 分发、受控流与缺失响应检查;`ok` | +| [`src/remote-proxy.ts`](src/remote-proxy.ts) | 命名空间/方法查找与生成映射的 mock 类型 | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本与 `MockStream`(句柄 + `AsyncIterable`) | +| [`src/log.ts`](src/log.ts) | 带共享 `seq` 计数器的日志 | +| — | 不发布运行时不变量伴生件;本测试支持库不拥有任何生产事件流或可变进程状态,其行为由本包测试覆盖。 | + +
+ +----- + + +## 模型体验 + +无;本包是浏览器侧测试基础设施,无一物到达模型请求。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **仅进程内载体**——`rpc` 经 `__DSH_TRANSPORT__.rpc` 服务同一 realm 的客户端;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。 +- **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。 +- **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。 +- **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。 +- **原生流覆盖自行管理 iterable**——覆盖返回自有 iterable 时,不参与脚本流日志、`requests`、`opened`、`drained` 以及 `push` / `end` / `fail`;调用方也负责取消。原生调用断言仍然有效。需要这些控制能力的场景应使用已登记的流脚本。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index aae2fcf2cf..b95277a6fc 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -78,6 +78,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/test-support/client-runtime': 'Browser-side test infrastructure.', 'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.', 'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.', + 'packages/test-support/remote-mock': 'Browser-side test infrastructure; mounts nothing into a product composition.', 'packages/typert/generator': 'Build-time generator run outside any agent runtime.', 'packages/typert/protocol': 'Compiler-independent protocol declarations.', 'packages/util/atomic-write': 'Zero-dependency filesystem write utility.', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 133f8736e5..448f3be1da 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -175,6 +175,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/loader-smoke': { kind: 'none', reason: 'The test harness submits an ordinary user task but delegates prompt and tool composition to the loaded tree.' }, 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, + 'packages/test-support/remote-mock': { kind: 'none', reason: 'Browser-side test infrastructure (Remote endpoint mock); registers nothing model-facing.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, From 3d6fa12a6dc8f0123c0191ae739895560db3071c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:17:53 +0800 Subject: [PATCH 14/19] feat(remote-mock): expose typed native mocks and controlled streams --- THIRD_PARTY_NOTICES.md | 1 + .../test-support/remote-mock/package.json | 42 ++ .../test-support/remote-mock/src/index.ts | 16 + packages/test-support/remote-mock/src/log.ts | 131 ++++++ .../remote-mock/src/remote-mock.ts | 385 ++++++++++++++++++ .../remote-mock/src/remote-proxy.ts | 29 ++ .../test-support/remote-mock/src/streams.ts | 192 +++++++++ .../tests/proxy-types.client.spec.ts | 184 +++++++++ .../remote-mock/tests/proxy.client.spec.ts | 269 ++++++++++++ .../remote-mock/tests/rpc.client.spec.ts | 43 ++ .../remote-mock/tests/streams.client.spec.ts | 183 +++++++++ .../remote-mock/tests/unary.client.spec.ts | 107 +++++ .../test-support/remote-mock/tsconfig.json | 21 + .../test-support/remote-mock/tsdown.config.ts | 6 + pnpm-lock.yaml | 22 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 17 files changed, 1633 insertions(+) create mode 100644 packages/test-support/remote-mock/package.json create mode 100644 packages/test-support/remote-mock/src/index.ts create mode 100644 packages/test-support/remote-mock/src/log.ts create mode 100644 packages/test-support/remote-mock/src/remote-mock.ts create mode 100644 packages/test-support/remote-mock/src/remote-proxy.ts create mode 100644 packages/test-support/remote-mock/src/streams.ts create mode 100644 packages/test-support/remote-mock/tests/proxy-types.client.spec.ts create mode 100644 packages/test-support/remote-mock/tests/proxy.client.spec.ts create mode 100644 packages/test-support/remote-mock/tests/rpc.client.spec.ts create mode 100644 packages/test-support/remote-mock/tests/streams.client.spec.ts create mode 100644 packages/test-support/remote-mock/tests/unary.client.spec.ts create mode 100644 packages/test-support/remote-mock/tsconfig.json create mode 100644 packages/test-support/remote-mock/tsdown.config.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 31eebdab8d..2a5455dd45 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -169,6 +169,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | +| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT | | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT | diff --git a/packages/test-support/remote-mock/package.json b/packages/test-support/remote-mock/package.json new file mode 100644 index 0000000000..a78d2ca758 --- /dev/null +++ b/packages/test-support/remote-mock/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-remote-mock", + "description": "Endpoint-named mock for Typert Remote traffic: unary answers and stream scripts per /, live stream control, a log, and the Connection carrier face whole-client specs install", + "version": "0.1.5-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/test-support/remote-mock" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^4.1.8" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "typescript": "^6.0.3" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/test-support/remote-mock/src/index.ts b/packages/test-support/remote-mock/src/index.ts new file mode 100644 index 0000000000..9c78ee3a27 --- /dev/null +++ b/packages/test-support/remote-mock/src/index.ts @@ -0,0 +1,16 @@ +/** + * Endpoint-named mock for Typert Remote traffic: a table of unary answers and + * stream scripts keyed by `/`, scripted-stream control, a + * carrier log, and the Connection carrier face the `connection` plugin + * accepts through `__DSH_TRANSPORT__.rpc`. Values are whatever the test + * registers; the only declaration is whether an endpoint is unary or a + * stream. Browser-safe: no DOM, React, or Node imports, no runtime import + * from another harness package. + * @module @deepseek-ai/dsh-remote-mock + */ +export { RemoteMock, ok } from './remote-mock.ts' +export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts' +export { frames, openStream } from './streams.ts' +export type { StreamHandle, StreamScript } from './streams.ts' +export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts' +export type { MockedRemote } from './remote-proxy.ts' diff --git a/packages/test-support/remote-mock/src/log.ts b/packages/test-support/remote-mock/src/log.ts new file mode 100644 index 0000000000..723a11f67d --- /dev/null +++ b/packages/test-support/remote-mock/src/log.ts @@ -0,0 +1,131 @@ +/** What a `RemoteMock` saw: answered calls, opened streams, and requests without a rule. */ + +/** One unary call, logged when its rule is selected and settled when the rule does. */ +export interface LoggedCall { + readonly endpoint: string + /** Positional args as the caller passed them. */ + readonly args: readonly unknown[] + /** `pending` until the rule settles, then `answered` or `failed`. */ + readonly state: 'pending' | 'answered' | 'failed' + /** The answer once `answered`, the thrown error once `failed`. */ + readonly result: unknown + /** Global order across calls and stream opens. */ + readonly seq: number +} + +/** One stream open and its current state. */ +export interface LoggedStream { + readonly endpoint: string + /** Positional args the stream was opened with. */ + readonly args: readonly unknown[] + readonly state: 'open' | 'ended' | 'failed' | 'cancelled' + /** Items delivered to the consumer so far. */ + readonly pushed: number + /** Global order across calls and stream opens. */ + readonly seq: number +} + +/** One request that found no rule. */ +export interface LoggedMiss { + readonly endpoint: string + readonly mode: 'unary' | 'stream' +} + +/** Read side of the log. */ +export interface MockLog { + /** + * Unary calls, oldest first, with their live state. + * @param endpoint - restrict to one endpoint. + * @returns matching entries. + */ + calls(endpoint?: string): readonly LoggedCall[] + /** + * Stream opens, oldest first, with their live state. + * @param endpoint - restrict to one endpoint. + * @returns matching entries. + */ + streams(endpoint?: string): readonly LoggedStream[] + /** + * The first positional arg of every unary call and stream open, oldest first: the request object of the Gateway's + * one-parameter endpoints. Without `endpoint`, every endpoint the client asked the Host for, except the Gateway's + * own `$`-prefixed ones (`$events`). + * @param endpoint - restrict to one endpoint. + * @returns the requests. + */ + requests(endpoint?: string): readonly unknown[] + /** Requests that found no rule, oldest first. */ + unmatched(): readonly LoggedMiss[] +} + +/** Mutable call entry the dispatcher settles. */ +export interface CallRecord extends LoggedCall { + state: LoggedCall['state'] + result: unknown +} + +/** Mutable stream entry shared with the live stream. */ +export interface StreamRecord extends LoggedStream { + state: LoggedStream['state'] + pushed: number +} + +/** The mock's log store. */ +export class MockLogStore implements MockLog { + private readonly callEntries: CallRecord[] = [] + private readonly streamEntries: StreamRecord[] = [] + private readonly missEntries: LoggedMiss[] = [] + private seq = 0 + + calls(endpoint?: string): readonly LoggedCall[] { + return this.callEntries.filter(entry => endpoint === undefined || entry.endpoint === endpoint) + } + + streams(endpoint?: string): readonly LoggedStream[] { + return this.streamEntries.filter(entry => endpoint === undefined || entry.endpoint === endpoint) + } + + requests(endpoint?: string): readonly unknown[] { + const entries: (LoggedCall | LoggedStream)[] = [...this.calls(endpoint), ...this.streams(endpoint)] + return entries + .filter(entry => endpoint !== undefined || !entry.endpoint.startsWith('$')) + .sort((a, b) => a.seq - b.seq) + .map(entry => entry.args[0]) + } + + unmatched(): readonly LoggedMiss[] { + return [...this.missEntries] + } + + /** + * Record one call whose rule was selected. + * @param endpoint - endpoint. + * @param args - positional args. + * @returns the mutable entry the dispatcher settles. + */ + call(endpoint: string, args: readonly unknown[]): CallRecord { + const record: CallRecord = { endpoint, args, state: 'pending', result: undefined, seq: ++this.seq } + this.callEntries.push(record) + return record + } + + /** + * Record one stream open. + * @param endpoint - endpoint. + * @param args - positional args. + * @returns the mutable entry the stream updates. + */ + stream(endpoint: string, args: readonly unknown[]): StreamRecord { + const record: StreamRecord = { endpoint, args, state: 'open', pushed: 0, seq: ++this.seq } + this.streamEntries.push(record) + return record + } + + /** + * Record one request without a rule. + * @param endpoint - endpoint. + * @param mode - how it was requested. + */ + miss(endpoint: string, mode: 'unary' | 'stream'): void { + this.missEntries.push({ endpoint, mode }) + } +} diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts new file mode 100644 index 0000000000..00d10668a3 --- /dev/null +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -0,0 +1,385 @@ +/** `RemoteMock`: an endpoint table (unary answers or stream scripts), live stream control, a log, and the Connection carrier face. */ + +import type { ClientConnectionRpc, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection/client' +import { fn, type Mock } from '@vitest/spy' +import { MockLogStore, type MockLog } from './log.ts' +import { MockStream, toError, type StreamScript } from './streams.ts' +import { createRemoteProxy, type MockedRemote } from './remote-proxy.ts' + +/** A unary handler receives the caller's positional arguments, without its trailing `AbortSignal`. */ +export type UnaryRuleFn = (...args: Args) => Result + +/** Endpoint defaults: unary values or positional handlers, stream scripts, and stream declarations. */ +export interface RemoteTable { + readonly unary?: Readonly> + readonly stream?: Readonly> + /** Endpoints declared as streams without a script, so an open finds a stream miss rather than a unary one. */ + readonly streams?: readonly string[] +} + +/** Construction options. */ +export interface RemoteMockOptions { + /** Host facts the built-in `$events` ready frame carries; default `{ home: '/home/mock' }`. */ + readonly host?: { readonly home: string } +} + +/** Filter over the args a stream was opened with. */ +export type StreamFilter = (args: readonly unknown[]) => boolean + +/** Control and synchronization for streams opened by registered scripts; native overrides are excluded. */ +export interface OpenStreams { + /** + * Push one item into every open stream on `endpoint`, optionally filtered by its open args. + * @param endpoint - endpoint. + * @param item - item to deliver. + * @param where - filter over open args. + * @returns how many streams received it. + */ + push(endpoint: string, item: unknown, where?: StreamFilter): number + /** + * End every matching open stream. + * @param endpoint - endpoint. + * @param where - filter over open args. + * @returns how many streams ended. + */ + end(endpoint: string, where?: StreamFilter): number + /** + * Fail every matching open stream with `error`. + * @param endpoint - endpoint. + * @param error - error the consumer's read rejects with. + * @param where - filter over open args. + * @returns how many streams failed. + */ + fail(endpoint: string, error: Error, where?: StreamFilter): number + /** + * Resolve once registered scripts have opened `endpoint` at least `count` times in total. + * @param endpoint - endpoint. + * @param count - opens to wait for. + */ + opened(endpoint: string, count: number): Promise + /** + * Resolve once the consumer of every matching stream has pulled everything pushed so far and, for a stream still + * open, waits for more; a settled stream counts once its queue is empty, and a stream nobody reads never drains. + * Pulled means taken from the queue: a consumer that processes items asynchronously after pulling them may still + * be working on the last one. + * @param endpoint - endpoint. + * @param where - filter over open args. + */ + drained(endpoint: string, where?: StreamFilter): Promise +} + +const EVENTS_ENDPOINT = '$events' + +/** + * The success envelope the Client's Remote callers read: `{ ok: true, value }`. + * @param value - success value. + * @returns the envelope. + */ +export function ok(value: T): { readonly ok: true; readonly value: T } { + return { ok: true, value } +} + +interface OpenWaiter { + readonly endpoint: string + readonly count: number + resolve(): void +} + +/** A missing default answer is an unmatched request, not a selected rule that failed. */ +class MissingUnaryRule extends Error {} + +/** + * Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the + * same core as the Connection carrier the `connection` plugin accepts through + * `__DSH_TRANSPORT__.rpc`. + */ +export class RemoteMock { + /** + * Create a mock whose `$events` stream answers the Gateway client's opening + * with one ready frame and then stays open, so the assembled client connects. + * @param options - host facts for the ready frame. + * @returns the mock. + */ + static create(options: RemoteMockOptions = {}): RemoteMock { + const mock = new RemoteMock() + const host = options.host ?? { home: '/home/mock' } + let generation = 0 + return mock.stream(EVENTS_ENDPOINT, (_args, stream) => { + generation += 1 + stream.push({ type: 'ready', clientId: `mock-client-${String(generation)}`, host }) + }) + } + + private readonly unaryDefaults = new Map() + private readonly scripts = new Map() + private live: MockStream[] = [] + private openWaiters: OpenWaiter[] = [] + private readonly logStore = new MockLogStore() + private readonly unaryMocks = new Map>() + private readonly streamMocks = new Map>() + private readonly proxy = createRemoteProxy(endpoint => this.modeOf(endpoint) === 'stream' + ? this.streamMock(endpoint) + : this.unaryMock(endpoint)) + + /** + * Native mock functions for every namespace: configure answers and inspect calls without declaring method names. + * Explicit stream registrations select stream mocks; all other methods use unary mocks. + * Native overrides belong to this instance; `mockReset()` restores the implementation reading current defaults. + * Generated declarations provide exact signatures; absent declarations weaken only this test-owned proxy. + */ + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Only this test proxy becomes any without generated namespaces. + readonly remote: MockedRemote = this.proxy as MockedRemote + + /** Everything observed so far. */ + readonly log: MockLog = this.logStore + + /** Control over streams opened by registered scripts. */ + readonly streams: OpenStreams = { + push: (endpoint, item, where) => this.forEachOpen(endpoint, where, (stream) => { stream.push(item) }), + end: (endpoint, where) => this.forEachOpen(endpoint, where, (stream) => { stream.end() }), + fail: (endpoint, error, where) => this.forEachOpen(endpoint, where, (stream) => { stream.fail(error) }), + opened: (endpoint, count) => { + if (this.logStore.streams(endpoint).length >= count) return Promise.resolve() + return new Promise((resolve) => { this.openWaiters.push({ endpoint, count, resolve }) }) + }, + drained: (endpoint, where) => Promise.all(this.matching(endpoint, where).map(stream => stream.drained())).then(() => undefined), + } + + /** + * The Connection carrier face: `call` dispatches, `open` opens; payloads carry + * `{ args }` as the whole-client proxies (an array) or the Gateway's own + * endpoints (one object) send them, and a call aborted by its signal rejects. + */ + readonly rpc: ClientConnectionRpc = { + call: async (_channel, endpoint, payload, signal) => { + const pending = this.dispatch(endpoint, argsOf(endpoint, payload)) + const value = await (signal === undefined ? pending : settleOrAbort(pending, signal)) + // The carrier contract names the result envelope; the registered value is taken as that envelope unchecked. + return value as ConnectionRpcResult + }, + open: (_channel, endpoint, payload, signal) => this.open(endpoint, argsOf(endpoint, payload), signal), + } + + private constructor() {} + + /** + * Set the endpoint's default positional-argument handler without changing its native mock overrides. + * @param endpoint - `/`. + * @param rule - handler computing the answer. + * @returns this. + */ + unary(endpoint: string, rule: UnaryRuleFn): this + /** + * Set the endpoint's default answer, including explicit `undefined`, without changing its native mock overrides. + * @param endpoint - `/`. + * @param rule - default answer. + * @returns this. + */ + unary(endpoint: string, rule: unknown): this + unary(endpoint: string, rule: unknown): this { + this.unaryDefaults.set(endpoint, rule) + return this + } + + /** + * Declare `endpoint` as a stream and replace its default script when supplied. A declaration without a script + * preserves any existing script; an endpoint declared without one fails an open as a stream miss. + * @param endpoint - `/`. + * @param script - script (`frames` / `openStream` build the common ones). + * @returns this. + */ + stream(endpoint: string, script?: StreamScript): this { + if (script !== undefined || !this.scripts.has(endpoint)) this.scripts.set(endpoint, script) + return this + } + + /** + * Register endpoint defaults without changing native mock overrides. + * @param table - unary answers and stream scripts. + * @returns this. + */ + load(table: RemoteTable): this { + for (const [endpoint, rule] of Object.entries(table.unary ?? {})) this.unary(endpoint, rule) + for (const endpoint of table.streams ?? []) this.stream(endpoint) + for (const [endpoint, script] of Object.entries(table.stream ?? {})) this.stream(endpoint, script) + return this + } + + /** + * Whether `endpoint` is declared a stream (with or without a script) or has a unary rule — the one declaration + * the whole-client proxies need; an endpoint neither declared nor ruled is dispatched as a unary call. + * @param endpoint - endpoint. + * @returns the mode, or undefined when nothing is registered. + */ + modeOf(endpoint: string): 'unary' | 'stream' | undefined { + if (this.scripts.has(endpoint)) return 'stream' + if (this.unaryDefaults.has(endpoint)) return 'unary' + return undefined + } + + /** + * Registered endpoints and accessed proxy methods, sorted. + * @returns endpoint names. + */ + endpoints(): readonly string[] { + const names = new Set(this.scripts.keys()) + for (const endpoint of this.unaryDefaults.keys()) names.add(endpoint) + for (const [namespace, methods] of Object.entries(this.proxy)) { + for (const method of Object.keys(methods)) names.add(`${namespace}/${method}`) + } + return [...names].sort() + } + + /** + * Answer one unary call with the registered rule's value, verbatim. The call + * is logged as soon as its rule is selected and settles with the rule: a + * rule that throws or rejects fails the call with that error. + * @param endpoint - endpoint. + * @param args - positional args. + * @returns the answer. + * @throws {Error} when no rule is registered (logged as unmatched). + */ + async dispatch(endpoint: string, args: readonly unknown[]): Promise { + let answer: unknown + try { + answer = this.unaryMock(endpoint)(...args) + } catch (error) { + if (error instanceof MissingUnaryRule) throw error + answer = Promise.reject(toError(error)) + } + const record = this.logStore.call(endpoint, args) + try { + record.result = await answer + } catch (error) { + record.state = 'failed' + record.result = error + throw error + } + record.state = 'answered' + return record.result + } + + private unaryMock(endpoint: string): Mock { + let mock = this.unaryMocks.get(endpoint) + if (mock === undefined) { + mock = fn((...args: readonly unknown[]) => Promise.resolve(this.unaryAnswer(endpoint, args))) + this.unaryMocks.set(endpoint, mock) + } + return mock + } + + private unaryAnswer(endpoint: string, args: readonly unknown[]): unknown { + if (!this.unaryDefaults.has(endpoint)) { + this.logStore.miss(endpoint, 'unary') + throw new MissingUnaryRule(this.noRuleMessage(endpoint)) + } + const rule = this.unaryDefaults.get(endpoint) + return isRuleFn(rule) ? answerOf(rule, args) : rule + } + + /** + * Open through the endpoint's native mock; its default runs the registered script as a controlled stream. + * A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`. + * @param endpoint - endpoint. + * @param args - positional args. + * @param signal - consumer cancellation. + * @returns the controlled script stream or the native override's caller-owned iterable. + * @throws {Error} when the default runs without a registered script (logged as unmatched). + */ + open(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { + return this.streamMock(endpoint)(...args, signal) as AsyncIterable + } + + private streamMock(endpoint: string): Mock { + let mock = this.streamMocks.get(endpoint) + if (mock === undefined) { + mock = fn((...values: readonly unknown[]) => { + const args = [...values] + const signal = args.at(-1) instanceof AbortSignal ? args.pop() as AbortSignal : new AbortController().signal + return this.openScript(endpoint, args, signal) + }) + this.streamMocks.set(endpoint, mock) + } + return mock + } + + private openScript(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { + const script = this.scripts.get(endpoint) + if (script === undefined) { + this.logStore.miss(endpoint, 'stream') + throw new Error(this.noRuleMessage(endpoint)) + } + const stream = new MockStream(this.logStore.stream(endpoint, args), signal) + this.live.push(stream) + this.wakeOpened(endpoint) + stream.run(script, args) + return stream + } + + /** Throw when any request found no rule, naming the endpoints and the registered ones. */ + assertNoUnmatched(): void { + const unmatched = this.logStore.unmatched() + if (unmatched.length === 0) return + const lines = unmatched.map(entry => ` ${entry.endpoint} (${entry.mode})`) + throw new Error(`remote-mock: ${String(unmatched.length)} unmatched request(s):\n${lines.join('\n')}\nregistered: ${this.endpoints().join(', ')}`) + } + + private noRuleMessage(endpoint: string): string { + return `remote-mock: no rule for ${endpoint}; registered: ${this.endpoints().join(', ')}` + } + + /** Streams on `endpoint` still open or still holding items their consumer has not pulled; the rest are forgotten. */ + private matching(endpoint: string, where: StreamFilter | undefined): MockStream[] { + this.live = this.live.filter(stream => stream.record.state === 'open' || stream.queued > 0) + return this.live.filter(stream => stream.record.endpoint === endpoint && (where === undefined || where(stream.record.args))) + } + + private forEachOpen(endpoint: string, where: StreamFilter | undefined, action: (stream: MockStream) => void): number { + const targets = this.matching(endpoint, where).filter(stream => stream.record.state === 'open') + for (const stream of targets) action(stream) + return targets.length + } + + private wakeOpened(endpoint: string): void { + const opened = this.logStore.streams(endpoint).length + const ready = this.openWaiters.filter(waiter => waiter.endpoint === endpoint && opened >= waiter.count) + this.openWaiters = this.openWaiters.filter(waiter => !ready.includes(waiter)) + for (const waiter of ready) waiter.resolve() + } +} + +function isRuleFn(rule: unknown): rule is UnaryRuleFn { + return typeof rule === 'function' +} + +/** A rule's synchronous throw becomes a rejection so the call settles through one path. */ +function answerOf(rule: UnaryRuleFn, args: readonly unknown[]): unknown { + try { + return rule(...args) + } catch (error) { + return Promise.reject(toError(error)) + } +} + +/** Positional args from a carrier payload: the array the whole-client proxies send, or the one object the Gateway's own endpoints send. */ +function argsOf(endpoint: string, payload: unknown): readonly unknown[] { + if (typeof payload === 'object' && payload !== null && 'args' in payload) { + const { args } = payload + if (Array.isArray(args)) return args + if (typeof args === 'object' && args !== null) return [args] + } + throw new TypeError(`remote-mock: payload of ${endpoint} must be { args: unknown[] | object }`) +} + +/** + * Settle with the call, or reject with the abort reason first; the call itself + * always keeps a handler so its own outcome is never an unhandled rejection. + */ +function settleOrAbort(pending: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = (): void => { reject(signal.reason instanceof Error ? signal.reason : new Error('remote-mock: call aborted', { cause: signal.reason })) } + signal.addEventListener('abort', abort, { once: true }) + pending.then(resolve, reject).finally(() => { signal.removeEventListener('abort', abort) }) + if (signal.aborted) abort() + }) +} diff --git a/packages/test-support/remote-mock/src/remote-proxy.ts b/packages/test-support/remote-mock/src/remote-proxy.ts new file mode 100644 index 0000000000..c3abe542a0 --- /dev/null +++ b/packages/test-support/remote-mock/src/remote-proxy.ts @@ -0,0 +1,29 @@ +/** Lazy namespace access for native Remote mocks; production Remote declarations stay unchanged. */ +import type { MaybeMockedDeep } from '@vitest/spy' +import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol' + +/** + * Native mocks for every generated Remote namespace, or an unrestricted local proxy without generated types. + * @template Api - generated namespace map available in the caller's TypeScript program. + */ +// oxlint-disable-next-line typescript/no-explicit-any -- An unbuilt namespace map has neither method names nor signatures. +export type MockedRemote = keyof Api extends never ? any : MaybeMockedDeep + +/** + * Cache accessed namespaces and expose the current native mock for each method. + * @param method - selects a stable endpoint mock for its configured invocation mode. + * @returns enumerable accessed namespaces and methods; symbols and thenable probes remain inert. + */ +export function createRemoteProxy(method: (endpoint: string) => unknown): Record { + return new Proxy(Object.create(null) as Record, { + get(namespaces, namespace) { + if (typeof namespace !== 'string' || namespace === 'then') return undefined + return namespaces[namespace] ??= new Proxy(Object.create(null) as Record, { + get(methods, name) { + if (typeof name !== 'string' || name === 'then') return undefined + return methods[name] = method(`${namespace}/${name}`) + }, + }) + }, + }) +} diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts new file mode 100644 index 0000000000..359753f307 --- /dev/null +++ b/packages/test-support/remote-mock/src/streams.ts @@ -0,0 +1,192 @@ +/** Stream scripts and the pushable, abort-aware stream a script drives. */ + +import type { StreamRecord } from './log.ts' + +/** Test-side controls over one open stream. */ +export interface StreamHandle { + /** Queue one item for the consumer. */ + push(item: unknown): void + /** End the stream after the queued items drain. */ + end(): void + /** Fail the consumer's next read with `error` after the queued items drain. */ + fail(error: Error): void + /** Consumer cancellation. */ + readonly signal: AbortSignal +} + +/** + * How a stream endpoint answers an open: receives the open args and the + * handle; the stream stays open after the script returns until `end()` or + * `fail()`. A script that throws or rejects fails the stream with that error. + */ +export type StreamScript = (args: readonly unknown[], stream: StreamHandle) => void | Promise + +/** + * Script yielding `items`, then ending. + * @param items - items in order. + * @returns the script. + */ +export function frames(items: readonly unknown[]): StreamScript { + return (_args, stream) => { + for (const item of items) stream.push(item) + stream.end() + } +} + +/** + * Script yielding `initial`, then staying open for `streams.push`. + * @param initial - items yielded on open. + * @returns the script. + */ +export function openStream(initial: readonly unknown[] = []): StreamScript { + return (_args, stream) => { + for (const item of initial) stream.push(item) + } +} + +type Settled = { readonly kind: 'end' } | { readonly kind: 'fail'; readonly error: Error } + +/** One open stream: a queue the script pushes into and a single consumer reads from; the log entry tracks its state. */ +export class MockStream implements StreamHandle, AsyncIterable { + private readonly queue: unknown[] = [] + private settled: Settled | undefined + private waiting: { readonly resolve: (result: IteratorResult) => void; readonly reject: (error: unknown) => void } | undefined + private drainWaiters: (() => void)[] = [] + + /** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */ + private readonly onAbort = (): void => { this.cancel() } + + /** Items pushed but not yet pulled by the consumer. */ + get queued(): number { + return this.queue.length + } + + /** + * @param record - log entry this stream updates. + * @param signal - consumer cancellation. + */ + constructor(readonly record: StreamRecord, readonly signal: AbortSignal) { + if (signal.aborted) this.cancel() + else signal.addEventListener('abort', this.onAbort, { once: true }) + } + + push(item: unknown): void { + if (this.record.state !== 'open') return + this.record.pushed += 1 + if (this.waiting !== undefined) { + const { resolve } = this.waiting + this.waiting = undefined + resolve({ value: item, done: false }) + return + } + this.queue.push(item) + } + + end(): void { + this.settle({ kind: 'end' }, 'ended') + } + + fail(error: Error): void { + this.settle({ kind: 'fail', error }, 'failed') + } + + /** + * Start `script` on this stream. + * @param script - the registered script. + * @param args - open args. + */ + run(script: StreamScript, args: readonly unknown[]): void { + let outcome: void | Promise + try { + outcome = script(args, this) + } catch (error) { + this.fail(toError(error)) + return + } + if (outcome instanceof Promise) void outcome.catch((error: unknown) => { this.fail(toError(error)) }) + } + + /** + * Resolve once the consumer has pulled every queued item and waits for the next one, or the stream is no longer + * open and holds nothing the consumer could still pull (a consumer that returns discards what it left). + * @returns settles when drained. + */ + drained(): Promise { + if (this.isDrained()) return Promise.resolve() + return new Promise((resolve) => { this.drainWaiters.push(resolve) }) + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => this.next(), + return: () => { + this.cancel() + return Promise.resolve({ value: undefined, done: true }) + }, + } + } + + private next(): Promise> { + const result = this.pull() + this.wakeDrained() + return result + } + + private pull(): Promise> { + if (this.queue.length > 0) return Promise.resolve({ value: this.queue.shift(), done: false }) + if (this.settled !== undefined) return this.finish(this.settled) + if (this.record.state === 'cancelled') return Promise.resolve({ value: undefined, done: true }) + if (this.waiting !== undefined) return Promise.reject(new Error(`remote-mock: ${this.record.endpoint} stream has one consumer`)) + return new Promise((resolve, reject) => { this.waiting = { resolve, reject } }) + } + + private finish(settled: Settled): Promise> { + return settled.kind === 'end' ? Promise.resolve({ value: undefined, done: true }) : Promise.reject(settled.error) + } + + private settle(settled: Settled, state: 'ended' | 'failed'): void { + if (this.record.state !== 'open') return + this.record.state = state + this.settled = settled + this.signal.removeEventListener('abort', this.onAbort) + this.wakeDrained() + // A pending read exists only while the queue is empty: push() resolves it directly instead of queueing. + const waiting = this.waiting + if (waiting === undefined) return + this.waiting = undefined + void this.finish(settled).then(waiting.resolve, waiting.reject) + } + + private cancel(): void { + if (this.record.state === 'open') { + this.record.state = 'cancelled' + this.signal.removeEventListener('abort', this.onAbort) + const waiting = this.waiting + this.waiting = undefined + waiting?.resolve({ value: undefined, done: true }) + } + // Whether cancelled or left after the producer settled, the consumer pulls nothing more: drop what it left. + this.queue.length = 0 + this.wakeDrained() + } + + private isDrained(): boolean { + return this.queue.length === 0 && (this.waiting !== undefined || this.record.state !== 'open') + } + + private wakeDrained(): void { + if (!this.isDrained()) return + const waiters = this.drainWaiters + this.drainWaiters = [] + for (const resolve of waiters) resolve() + } +} + +/** + * The `Error` a thrown value stands for: itself, or a new Error carrying its string form. + * @param reason - thrown value. + * @returns the error. + */ +export function toError(reason: unknown): Error { + return reason instanceof Error ? reason : new Error(String(reason)) +} diff --git a/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts new file mode 100644 index 0000000000..b1880d313f --- /dev/null +++ b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts @@ -0,0 +1,184 @@ +/** Mock-local unbuilt typing and native deep mocks over generated Remote declarations. */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import type { MaybeMockedDeep } from '@vitest/spy' +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { TypertRemoteNamespace, TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-settings-controller/remote' +import type { RemoteMock } from '../src/index.ts' + +const root = resolve(import.meta.dirname, '../../../..') +const probePath = resolve(import.meta.dirname, '__remote_proxy_probe.ts') +const artifactPath = resolve(import.meta.dirname, '__remote_proxy_artifact.d.ts') +const protocolPath = resolve(root, 'packages/typert/protocol/src/types.ts') +const proxyPath = resolve(import.meta.dirname, '../src/remote-proxy.ts') +const protocolEntry = resolve(import.meta.dirname, '__remote_protocol.d.ts') +const cordisEntry = resolve(import.meta.dirname, '__remote_cordis.d.ts') + +function modulePath(path: string): string { + return JSON.stringify(path.replaceAll('\\', '/')) +} + +function virtualKey(path: string): string { + const normalized = path.replaceAll('\\', '/') + return ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase() +} + +function compile(source: string, artifact?: string) { + const config = ts.readConfigFile(resolve(root, 'tsconfig.base.client.json'), filename => ts.sys.readFile(filename)) + if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root) + const options: ts.CompilerOptions = { + ...parsed.options, + composite: false, + incremental: false, + noEmit: true, + declaration: false, + declarationMap: false, + rewriteRelativeImportExtensions: false, + types: ['node'], + paths: { + '@deepseek-ai/cordis': [cordisEntry], + '@deepseek-ai/dsh-typert-protocol': [protocolEntry], + '@deepseek-ai/fixture/remote': [artifactPath], + }, + } + // The real protocol and Mock alias are compiled; only unrelated Cordis/runtime assembly is excluded. + const virtual = new Map([ + [virtualKey(probePath), source], + [virtualKey(protocolEntry), `export * from ${modulePath(protocolPath)}\nexport { RemoteError } from ${modulePath(resolve(root, 'packages/typert/protocol/src/remote-error.ts'))}`], + [virtualKey(cordisEntry), 'export interface Context {}\nexport interface Events {}'], + ]) + if (artifact !== undefined) virtual.set(virtualKey(artifactPath), artifact) + const host = ts.createCompilerHost(options, true) + const read = host.readFile.bind(host) + const exists = host.fileExists.bind(host) + const hidden = (path: string): boolean => path.replaceAll('\\', '/').includes('/typert.remote-client.') + host.fileExists = path => virtual.has(virtualKey(path)) || !hidden(path) && exists(path) + host.readFile = path => virtual.get(virtualKey(path)) ?? (hidden(path) ? undefined : read(path)) + host.getSourceFile = (path, languageVersion) => { + const text = host.readFile(path) + return text === undefined ? undefined : ts.createSourceFile(path, text, languageVersion, true) + } + const program = ts.createProgram([probePath], options, host) + expect(program.getSourceFile(protocolPath)).toBeDefined() + expect(program.getSourceFile(proxyPath)).toBeDefined() + expect(program.getSourceFiles().some(file => hidden(file.fileName))).toBe(false) + expect(program.getSourceFile(artifactPath) !== undefined).toBe(artifact !== undefined) + const diagnostics = ts.getPreEmitDiagnostics(program) + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: path => path, + getCurrentDirectory: () => root, + getNewLine: () => '\n', + }) + expect(diagnostics.length, formatted).toBe(0) +} + +describe('RemoteMock proxy types', { timeout: 60_000 }, () => { + it('keeps production closed while an empty generated map permits Mock calls and overrides', () => { + compile(` +import type { TypertRemoteNamespace, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { MockedRemote } from '../src/remote-proxy.ts' +type IsAny = 0 extends (1 & T) ? true : false +declare const local: MockedRemote +const loose: IsAny = true +local.fixture.echo(123, 'unbuilt') +local.fixture.echo.mockResolvedValueOnce({ arbitrary: 'answer' }) +local.fixture.echo.mockImplementation((value: unknown, extra: unknown) => [value, extra]) +declare const production: TypertClientRemote +declare const strict: TypertRemoteNamespace<'fixture'> +// @ts-expect-error -- Importing the Mock alias cannot add a production namespace. +production.fixture.echo('unbuilt') +// @ts-expect-error -- Production methods stay unavailable without generated declarations. +strict.echo('unbuilt') +void loose +`) + }) + + it('preserves generated arguments, results and native spy overrides', () => { + compile(` +import type { MockedRemote } from '../src/remote-proxy.ts' +import type {} from '@deepseek-ai/fixture/remote' +type IsAny = 0 extends (1 & T) ? true : false +declare const local: MockedRemote +const spy = local.fixture.echo +spy.mockImplementation(value => value.length) +const argumentIsTyped: IsAny[0]> = false +const resultIsTyped: IsAny> = false +const result: number = spy('typed') +spy.mockReturnValueOnce(2) +// @ts-expect-error -- Generated arguments remain checked through the native spy. +spy(123) +// @ts-expect-error -- Generated return values remain checked through native overrides. +spy.mockReturnValueOnce('wrong') +// @ts-expect-error -- Known namespaces do not gain arbitrary methods. +local.fixture.missing() +// @ts-expect-error -- A non-empty map does not gain arbitrary namespaces. +local.missing.echo('wrong') +void [argumentIsTyped, resultIsTyped, result] +`, fixtureDeclaration) + }) + + it('keeps partial and empty namespaces closed inside a non-empty map', () => { + compile(` +import type { TypertClientRemote, TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol' +import type { MockedRemote } from '../src/remote-proxy.ts' +import type {} from '@deepseek-ai/fixture/remote' +type PartialMap = { fixture: Pick, 'echo'>; empty: {} } +declare const local: MockedRemote +declare const production: TypertClientRemote +const known: number = local.fixture.echo('known') +// @ts-expect-error -- A partial namespace remains partial. +local.fixture.watch() +// @ts-expect-error -- Only an empty whole map enables the Mock fallback. +local.empty.echo() +// @ts-expect-error -- Local Mock types cannot open production namespace lookup. +production.notANamespace.echo() +void known +`, fixtureDeclaration) + }) + + // Ordinary built Client tsc checks these generated business types; Vitest itself remains build-free. + it('derives real settings mocks from the generated namespace map and native deep mapping', () => { + type Settings = RemoteMock['remote']['settings'] + type Generated = TypertRemoteNamespace<'settings'> + expectTypeOf().toEqualTypeOf>() + expectTypeOf>().not.toBeAny() + expectTypeOf>().not.toBeAny() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf[1]>().not.toBeAny() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf<[number, boolean, string]>().not.toExtend>() + expectTypeOf<{ ok: true; value: number }>().not.toExtend>>() + expectTypeOf().not.toHaveProperty('missing') + }) + + it('retains the native/generated type dependencies only in the proxy declaration', () => { + const source = readFileSync(proxyPath, 'utf8') + const declaration = ts.transpileDeclaration(source, { fileName: proxyPath }) + expect(declaration.diagnostics ?? []).toHaveLength(0) + expect(declaration.outputText).toContain('MaybeMockedDeep') + expect(declaration.outputText).toContain('TypertRemoteNamespaceMap') + const runtime = ts.transpileModule(source, { + fileName: proxyPath, + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2024 }, + }) + expect(runtime.outputText).not.toContain('@deepseek-ai/dsh-typert-protocol') + expect(runtime.outputText).not.toContain('@vitest/spy') + }) +}) + +/** Synthetic Remote methods exercise the mapping without copying business signatures. */ +const fixtureDeclaration = ` +import type { TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol' +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteMap { + 'fixture/echo': (value: string) => number + 'fixture/watch': (after: number, signal?: AbortSignal) => AsyncIterable + } + interface TypertRemoteNamespaceMap { + fixture: TypertRemoteNamespace<'fixture'> + } +} +` diff --git a/packages/test-support/remote-mock/tests/proxy.client.spec.ts b/packages/test-support/remote-mock/tests/proxy.client.spec.ts new file mode 100644 index 0000000000..38211cb6fe --- /dev/null +++ b/packages/test-support/remote-mock/tests/proxy.client.spec.ts @@ -0,0 +1,269 @@ +/** Native namespace mocks shared by local callers and the Connection carrier. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts' + +const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] }) +const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } } + +async function drain(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} + +describe('RemoteMock.remote unary proxies', () => { + it('caches native namespace/method identities and leaves thenable/symbol probes inert', async () => { + const mock = RemoteMock.create() + const settings = mock.remote.settings + const describe = settings.describe + expect(mock.remote.settings).toBe(settings) + expect(settings.describe).toBe(describe) + expect(vi.isMockFunction(describe)).toBe(true) + expect(RemoteMock.create().remote.settings.describe).not.toBe(describe) + for (const value of [mock.remote, settings]) { + expect(Reflect.get(value, 'then')).toBeUndefined() + expect(Reflect.get(value, Symbol.iterator)).toBeUndefined() + expect(Reflect.get(value, Symbol.toStringTag)).toBeUndefined() + expect(await Promise.resolve(value)).toBe(value) + } + expect(describe).not.toHaveBeenCalled() + expect(mock.endpoints()).toEqual(['$events', 'settings/describe']) + expect(mock.log.calls()).toEqual([]) + expect(mock.log.streams()).toEqual([]) + expect(mock.log.unmatched()).toEqual([]) + }) + + it('consumes native one-shot answers in FIFO order across direct and carrier calls, then uses the default handler', async () => { + const fallback = describeValue(false) + const local = describeValue(true) + const dispatched = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } } + const carrier = describeValue(true) + const handler = vi.fn(() => fallback) + const mock = RemoteMock.create().unary('settings/describe', handler) + const describe = mock.remote.settings.describe + describe.mockReturnValueOnce(Promise.resolve(local)).mockResolvedValueOnce(dispatched).mockResolvedValueOnce(carrier) + + await expect(describe()).resolves.toBe(local) + await expect(mock.dispatch('settings/describe', [])).resolves.toBe(dispatched) + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(carrier) + await expect(describe()).resolves.toBe(fallback) + + expect(handler).toHaveBeenCalledOnce() + expect(describe.mock.calls).toEqual([[], [], [], []]) + expect(mock.log.calls('settings/describe').map(call => [call.args, call.state, call.result])) + .toEqual([[[], 'answered', dispatched], [[], 'answered', carrier]]) + mock.assertNoUnmatched() + }) + + it('records every business argument while the carrier keeps its signal out of positional args', async () => { + const mock = RemoteMock.create() + const mutate = mock.remote.settings.mutate + const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 }) + const ops = [{ op: 'set' as const, path: ['preference'], value: 'en' }] + mutate.mockResolvedValue(result) + + await expect(mutate('locale', ops, 7)).resolves.toBe(result) + await expect(mock.rpc.call('/api', 'settings/mutate', { args: ['locale', ops, 7] }, new AbortController().signal)) + .resolves.toBe(result) + expect(mutate.mock.calls).toEqual([['locale', ops, 7], ['locale', ops, 7]]) + expect(mutate.mock.calls[1]?.[1]).toBe(ops) + expect(mock.log.calls('settings/mutate').map(call => call.args)).toEqual([['locale', ops, 7]]) + }) + + it('keeps native one-shot answers local to each mock when they load the same default table', async () => { + const fallback = describeValue(false) + const table = { unary: { 'settings/describe': fallback } } + const a = RemoteMock.create().load(table) + const b = RemoteMock.create().load(table) + const first = describeValue(true) + const second = { ...describeValue(true), value: { writable: false, hasDocument: true, namespaces: [] } } + a.remote.settings.describe.mockResolvedValueOnce(first) + b.remote.settings.describe.mockResolvedValueOnce(second) + + await expect(a.dispatch('settings/describe', [])).resolves.toBe(first) + await expect(a.dispatch('settings/describe', [])).resolves.toBe(fallback) + expect(b.remote.settings.describe).not.toHaveBeenCalled() + await expect(b.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(second) + await expect(b.remote.settings.describe()).resolves.toBe(fallback) + }) + + it('keeps queued overrides on mockClear and restores the live default rule on mockReset', async () => { + const original = describeValue(false) + const updated = describeValue(true) + const override = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } } + const mock = RemoteMock.create().unary('settings/describe', original) + const describe = mock.remote.settings.describe + await describe() + describe.mockResolvedValueOnce(override) + describe.mockClear() + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(override) + expect(describe.mock.calls).toEqual([[]]) + + describe.mockResolvedValue(override) + mock.load({ unary: { 'settings/describe': updated } }) + await expect(mock.dispatch('settings/describe', [])).resolves.toBe(override) + describe.mockReset() + await expect(mock.dispatch('settings/describe', [])).resolves.toBe(updated) + expect(mock.remote.settings.describe).toBe(describe) + expect(describe.mock.calls).toEqual([[]]) + expect(mock.log.calls('settings/describe').map(call => call.result)).toEqual([override, override, updated]) + }) + + it('reports missing defaults after native one-shot answers are exhausted', async () => { + const mock = RemoteMock.create() + const describe = mock.remote.settings.describe + expect(mock.modeOf('settings/describe')).toBeUndefined() + const answer = describeValue(false) + describe.mockResolvedValueOnce(answer) + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(answer) + expect(() => describe()).toThrow('no rule for settings/describe') + await expect(mock.dispatch('settings/describe', [])).rejects.toThrow('no rule for settings/describe') + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toThrow('no rule for settings/describe') + expect(describe).toHaveBeenCalledTimes(4) + expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['answered', answer]]) + expect(mock.log.unmatched()).toEqual(Array.from({ length: 3 }, () => ({ endpoint: 'settings/describe', mode: 'unary' }))) + expect(() => { mock.assertNoUnmatched() }).toThrow('3 unmatched request(s)') + }) + + it('records native rejected answers as failed carrier calls without unmatched entries', async () => { + const mock = RemoteMock.create() + const failure = new Error('mock service unavailable') + const describe = mock.remote.settings.describe.mockRejectedValueOnce(failure) + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toBe(failure) + expect(describe.mock.calls).toEqual([[]]) + expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]]) + mock.assertNoUnmatched() + }) + + it('preserves synchronous native override failures in the async carrier log', async () => { + const mock = RemoteMock.create() + const failure = new Error('handler threw') + const describe = mock.remote.settings.describe.mockImplementation(() => { throw failure }) + await expect(mock.dispatch('settings/describe', [])).rejects.toBe(failure) + expect(describe).toHaveBeenCalledOnce() + expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]]) + mock.assertNoUnmatched() + }) +}) + +describe('RemoteMock.remote stream proxies', () => { + it('selects the cached spy for the current mode and preserves its identity across script replacement', async () => { + const mock = RemoteMock.create() + const session = mock.remote.session + const unary = session.control + expect(() => unary()).toThrow('no rule for session/control') + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'unary' }]) + + mock.stream('session/control', frames([baseline])).stream('session/control') + const stream = session.control + expect(mock.remote.session).toBe(session) + expect(stream).not.toBe(unary) + expect(vi.isMockFunction(stream)).toBe(true) + expect(session.control).toBe(stream) + const opened = stream() + mock.stream('session/control', frames([])) + expect(session.control).toBe(stream) + await expect(drain(opened)).resolves.toEqual([baseline]) + await expect(drain(stream())).resolves.toEqual([]) + expect(unary).toHaveBeenCalledOnce() + expect(stream).toHaveBeenCalledTimes(2) + expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['ended', 'ended']) + }) + + it('shares stream spies with the carrier, preserves its args, and forwards each caller signal', async () => { + const signals: AbortSignal[] = [] + const script = vi.fn((_args: readonly unknown[], stream: StreamHandle) => { + signals.push(stream.signal) + stream.push(baseline) + }) + const mock = RemoteMock.create().stream('session/control', script) + const localController = new AbortController() + const wireController = new AbortController() + onTestFinished(async () => { + localController.abort() + wireController.abort() + await mock.streams.drained('session/control') + }) + const control = mock.remote.session.control + const request = { after: 7 } + const local = control(localController.signal)[Symbol.asyncIterator]() + const wire = mock.rpc.open!('/api', 'session/control', { args: [request] }, wireController.signal)[Symbol.asyncIterator]() + await expect(local.next()).resolves.toEqual({ value: baseline, done: false }) + await expect(wire.next()).resolves.toEqual({ value: baseline, done: false }) + expect(control.mock.calls).toEqual([[localController.signal], [request, wireController.signal]]) + expect(script.mock.calls.map(([args]) => args)).toEqual([[], [request]]) + expect(signals).toEqual([localController.signal, wireController.signal]) + expect(mock.log.streams('session/control').map(entry => entry.args)).toEqual([[], [request]]) + + const waiting = local.next() + localController.abort() + await expect(waiting).resolves.toEqual({ value: undefined, done: true }) + await wire.return!() + await mock.streams.drained('session/control') + expect(mock.streams.push('session/control', baseline)).toBe(0) + expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['cancelled', 'cancelled']) + }) + + it('provides a cancellable default signal when a local stream call omits one', async () => { + let openedSignal: AbortSignal | undefined + const mock = RemoteMock.create().stream('session/control', (_args, stream) => { openedSignal = stream.signal }) + const control = mock.remote.session.control + const reader = control()[Symbol.asyncIterator]() + onTestFinished(async () => { await reader.return!() }) + expect(openedSignal).toBeInstanceOf(AbortSignal) + expect(openedSignal?.aborted).toBe(false) + const waiting = reader.next() + await reader.return!() + await expect(waiting).resolves.toEqual({ value: undefined, done: true }) + expect(control.mock.calls).toEqual([[]]) + expect(mock.log.streams('session/control')[0]?.state).toBe('cancelled') + }) + + it('excludes unread native stream overrides from controlled streams and restores the script on reset', async () => { + const mock = RemoteMock.create().stream('session/control', frames([baseline])) + const control = mock.remote.session.control + const signal = new AbortController().signal + let consumed = false + async function* response() { + consumed = true + yield baseline + } + const native = response() + onTestFinished(async () => { await native.return(undefined) }) + control.mockReturnValueOnce(native) + const overridden = mock.open('session/control', [], signal) + expect(overridden).toBe(native) + await expect(mock.streams.drained('session/control')).resolves.toBeUndefined() + expect(consumed).toBe(false) + expect(control.mock.calls).toEqual([[signal]]) + expect(mock.log.streams('session/control')).toHaveLength(0) + await expect(drain(overridden)).resolves.toEqual([baseline]) + expect(consumed).toBe(true) + + control.mockReset() + await expect(drain(mock.rpc.open!('/api', 'session/control', { args: [] }, signal))).resolves.toEqual([baseline]) + await expect(mock.streams.opened('session/control', 1)).resolves.toBeUndefined() + await expect(mock.streams.drained('session/control')).resolves.toBeUndefined() + expect(mock.remote.session.control).toBe(control) + expect(control.mock.calls).toEqual([[signal]]) + expect(mock.log.streams('session/control').map(entry => [entry.state, entry.pushed])).toEqual([['ended', 1]]) + mock.assertNoUnmatched() + }) + + it('requires an explicit stream script and keeps live failures observable by the consumer', async () => { + const mock = RemoteMock.create().stream('session/control') + expect(() => mock.remote.session.control()).toThrow('no rule for session/control') + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'stream' }]) + mock.stream('session/control', openStream()) + const controller = new AbortController() + onTestFinished(() => { controller.abort() }) + const reader = mock.remote.session.control(controller.signal)[Symbol.asyncIterator]() + const failure = new Error('stream disconnected') + const pending = expect(reader.next()).rejects.toBe(failure) + expect(mock.streams.fail('session/control', failure)).toBe(1) + await pending + await mock.streams.drained('session/control') + expect(mock.log.streams('session/control')[0]?.state).toBe('failed') + expect(mock.streams.push('session/control', baseline)).toBe(0) + }) +}) diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts new file mode 100644 index 0000000000..2eb0a620bc --- /dev/null +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -0,0 +1,43 @@ +/** The Connection carrier face: payload forms, streams, unmatched rejections, and abort. */ +import { describe, expect, it } from 'vitest' +import { RemoteMock, ok, openStream } from '../src/index.ts' + +const idle = (): AbortSignal => new AbortController().signal + +describe('RemoteMock.rpc', () => { + it('calls and opens by endpoint, taking the proxies\' positional args or the Gateway\'s single object', async () => { + const mock = RemoteMock.create() + .unary('session/list', ok({ items: [] })) + .unary('$events/result', (...args) => ok(args)) + .stream('session/control', openStream([{ type: 'baseline' }])) + await expect(mock.rpc.call('/api', 'session/list', { args: [{}] })).resolves.toEqual({ ok: true, value: { items: [] } }) + await expect(mock.rpc.call('/api', '$events/result', { args: { clientId: 'c' } }, idle())).resolves.toEqual({ ok: true, value: [{ clientId: 'c' }] }) + const stream = mock.rpc.open!('/api', 'session/control', { args: [{ since: 1 }] }, idle())[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ value: { type: 'baseline' }, done: false }) + expect(mock.log.calls().map(call => [call.endpoint, call.args])).toEqual([['session/list', [{}]], ['$events/result', [{ clientId: 'c' }]]]) + expect(mock.log.streams('session/control').map(open => open.args)).toEqual([[{ since: 1 }]]) + }) + + it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { + const mock = RemoteMock.create() + await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }') + await expect(mock.rpc.call('/api', 'a/b', { args: 'x' })).rejects.toThrow('must be { args: unknown[] | object }') + expect(() => mock.rpc.open!('/api', 'a/b', null, idle())).toThrow('payload of a/b must be { args: unknown[] | object }') + await expect(mock.rpc.call('/api', 'a/b', { args: [] })).rejects.toThrow('remote-mock: no rule for a/b') + expect(mock.log.unmatched()).toEqual([{ endpoint: 'a/b', mode: 'unary' }]) + }) + + it('rejects an aborted call: immediately when already aborted, with the reason while the rule is pending', async () => { + const mock = RemoteMock.create().unary('slow/call', () => new Promise(() => {})) + const pending = new AbortController() + const request = mock.rpc.call('/api', 'slow/call', { args: [] }, pending.signal) + pending.abort(new Error('caller left')) + await expect(request).rejects.toThrow('caller left') + const already = new AbortController() + already.abort('not an error') + await expect(mock.rpc.call('/api', 'slow/call', { args: [] }, already.signal)).rejects.toThrow('remote-mock: call aborted') + await expect(mock.rpc.call('/api', 'session/list', { args: [] }, idle())).rejects.toThrow('no rule for session/list') + // The abort rejects the caller; the never-settling rules stay pending in the log, and the miss is not a call. + expect(mock.log.calls().map(call => [call.endpoint, call.state])).toEqual([['slow/call', 'pending'], ['slow/call', 'pending']]) + }) +}) diff --git a/packages/test-support/remote-mock/tests/streams.client.spec.ts b/packages/test-support/remote-mock/tests/streams.client.spec.ts new file mode 100644 index 0000000000..b65ae8ce8a --- /dev/null +++ b/packages/test-support/remote-mock/tests/streams.client.spec.ts @@ -0,0 +1,183 @@ +/** Stream scripts, live stream control, cancellation, and the built-in `$events` opening. */ +import { describe, expect, it } from 'vitest' +import { RemoteMock, frames, openStream } from '../src/index.ts' + +const idle = (): AbortSignal => new AbortController().signal + +async function drain(source: AsyncIterable): Promise { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} + +async function take(source: AsyncIterable, count: number): Promise { + const items: unknown[] = [] + for await (const item of source) { + items.push(item) + if (items.length === count) break + } + return items +} + +describe('RemoteMock streams', () => { + it('yields frames() then ends, logging the open, and throws on an unmatched endpoint', async () => { + const mock = RemoteMock.create().stream('s/f', frames([{ n: 1 }, { n: 2 }])) + await expect(drain(mock.open('s/f', [{ id: 'a' }], idle()))).resolves.toEqual([{ n: 1 }, { n: 2 }]) + expect(mock.log.streams('s/f')).toEqual([{ endpoint: 's/f', args: [{ id: 'a' }], state: 'ended', pushed: 2, seq: 1 }]) + expect(mock.log.streams('other')).toEqual([]) + expect(() => mock.open('s/g', [], idle())).toThrow('remote-mock: no rule for s/g; registered: $events, s/f') + expect(mock.log.unmatched()).toEqual([{ endpoint: 's/g', mode: 'stream' }]) + }) + + it('keeps openStream() open for pushes, filters by open args, and reports delivery counts', async () => { + const mock = RemoteMock.create().stream('s/f', openStream(['hello'])) + const sessionOf = ([request]: readonly unknown[]): string => (request as { sessionId: string }).sessionId + const a = mock.open('s/f', [{ sessionId: 'a' }], idle()) + const b = mock.open('s/f', [{ sessionId: 'b' }], idle()) + expect(mock.streams.push('s/f', 'only-b', open => sessionOf(open) === 'b')).toBe(1) + expect(mock.streams.push('s/f', 'both')).toBe(2) + expect(mock.streams.fail('s/f', new Error('gone'), open => sessionOf(open) === 'a')).toBe(1) + expect(mock.streams.end('s/f')).toBe(1) + await expect(drain(b)).resolves.toEqual(['hello', 'only-b', 'both']) + await expect(drain(a)).rejects.toThrow('gone') + expect(mock.log.streams().map(entry => [entry.state, entry.pushed])).toEqual([['failed', 2], ['ended', 3]]) + expect(mock.streams.push('s/f', 'late')).toBe(0) + }) + + it('resolves a pending read on push, end, or fail; a second concurrent read is a bug; settling twice is a no-op', async () => { + const mock = RemoteMock.create().stream('s/f', openStream()) + const first = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + const pending = first.next() + await expect(first.next()).rejects.toThrow('remote-mock: s/f stream has one consumer') + mock.streams.push('s/f', 'x') + await expect(pending).resolves.toEqual({ value: 'x', done: false }) + const ending = first.next() + mock.streams.end('s/f') + await expect(ending).resolves.toEqual({ value: undefined, done: true }) + const second = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + const failing = second.next() + mock.streams.fail('s/f', new Error('boom')) + await expect(failing).rejects.toThrow('boom') + await expect(second.next()).rejects.toThrow('boom') + expect(mock.streams.end('s/f')).toBe(0) + const settled = new AbortController() + const ended = mock.open('s/f', [], settled.signal) + mock.streams.end('s/f') + settled.abort() + await expect(drain(ended)).resolves.toEqual([]) + expect(mock.log.streams().map(entry => entry.state)).toEqual(['ended', 'failed', 'ended']) + }) + + it('lets a consumer that returns after the producer ended drop the rest, so drained() settles', async () => { + const mock = RemoteMock.create().stream('s/f', frames(['a', 'b'])) + const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + await expect(reader.next()).resolves.toEqual({ value: 'a', done: false }) + const pending = mock.streams.drained('s/f') + await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) + await expect(pending).resolves.toBeUndefined() + expect(mock.log.streams('s/f')).toEqual([{ endpoint: 's/f', args: [], state: 'ended', pushed: 2, seq: 1 }]) + expect(mock.streams.push('s/f', 'late')).toBe(0) + }) + + it('treats consumer abort or return() as cancellation and drops later pushes', async () => { + const mock = RemoteMock.create().stream('s/f', openStream(['queued'])) + const controller = new AbortController() + const cancelled = mock.open('s/f', [], controller.signal)[Symbol.asyncIterator]() + await expect(cancelled.next()).resolves.toEqual({ value: 'queued', done: false }) + const waiting = cancelled.next() + controller.abort() + await expect(waiting).resolves.toEqual({ value: undefined, done: true }) + expect(mock.streams.push('s/f', 'after')).toBe(0) + const aborted = new AbortController() + aborted.abort() + await expect(drain(mock.open('s/f', [], aborted.signal))).resolves.toEqual([]) + await expect(take(mock.open('s/f', [], idle()), 1)).resolves.toEqual(['queued']) + expect(mock.log.streams().map(entry => entry.state)).toEqual(['cancelled', 'cancelled', 'cancelled']) + }) + + it('runs script functions with the open args and fails the stream when they throw or reject', async () => { + const mock = RemoteMock.create() + .stream('s/echo', (args, stream) => { + stream.push(args) + stream.end() + stream.end() + stream.fail(new Error('too late')) + }) + .stream('s/async', async (_args, stream) => { + await Promise.resolve() + stream.push('later') + stream.end() + }) + .stream('s/throws', () => { throw new Error('sync boom') }) + .stream('s/rejects', () => Promise.reject(new Error('async boom'))) + .stream('s/odd', () => { throw 'string reason' }) + await expect(drain(mock.open('s/echo', [{ a: 1 }], idle()))).resolves.toEqual([[{ a: 1 }]]) + await expect(drain(mock.open('s/async', [], idle()))).resolves.toEqual(['later']) + await expect(drain(mock.open('s/throws', [], idle()))).rejects.toThrow('sync boom') + await expect(drain(mock.open('s/rejects', [], idle()))).rejects.toThrow('async boom') + await expect(drain(mock.open('s/odd', [], idle()))).rejects.toThrow('string reason') + }) + + it('drained() settles once the consumer has pulled every push and waits again, or the stream closed', async () => { + const mock = RemoteMock.create().stream('s/f', openStream(['first'])) + await expect(mock.streams.drained('s/f')).resolves.toBeUndefined() // nothing open: nothing to drain + const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + const unread = mock.streams.drained('s/f') + let settled = false + void unread.then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) // 'first' is queued and nobody has pulled it + await expect(reader.next()).resolves.toEqual({ value: 'first', done: false }) + await Promise.resolve() + expect(settled).toBe(false) // pulled, but the consumer is not waiting for more yet + const waiting = reader.next() + await expect(unread).resolves.toBeUndefined() + mock.streams.push('s/f', 'second') + await expect(waiting).resolves.toEqual({ value: 'second', done: false }) + mock.streams.push('s/f', 'third') + mock.streams.end('s/f') + const ended = mock.streams.drained('s/f') + let endedSettled = false + void ended.then(() => { endedSettled = true }) + await Promise.resolve() + expect(endedSettled).toBe(false) // ended, but 'third' is still queued + await expect(reader.next()).resolves.toEqual({ value: 'third', done: false }) + await expect(ended).resolves.toBeUndefined() // queue empty: a settled stream counts as drained + await expect(reader.next()).resolves.toEqual({ value: undefined, done: true }) + await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) // returning a settled stream changes nothing + const second = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + await expect(second.next()).resolves.toEqual({ value: 'first', done: false }) + const parked = second.next() + await expect(mock.streams.drained('s/f')).resolves.toBeUndefined() // its consumer is waiting + const controller = new AbortController() + const third = mock.open('s/f', [], controller.signal) + const cancelling = mock.streams.drained('s/f', () => true) // third holds 'first' that nobody has read + controller.abort() + await expect(cancelling).resolves.toBeUndefined() // cancellation discards the queue: closed counts as drained + await expect(drain(third)).resolves.toEqual([]) + mock.streams.end('s/f') + await expect(parked).resolves.toEqual({ value: undefined, done: true }) + }) + + it('declares a stream without a script: modeOf answers stream and an open is a stream miss', () => { + const mock = RemoteMock.create().load({ streams: ['s/declared'] }) + expect(mock.modeOf('s/declared')).toBe('stream') + expect(mock.endpoints()).toEqual(['$events', 's/declared']) + expect(() => mock.open('s/declared', [], idle())).toThrow('remote-mock: no rule for s/declared; registered: $events, s/declared') + expect(mock.log.unmatched()).toEqual([{ endpoint: 's/declared', mode: 'stream' }]) + mock.stream('s/declared', frames(['now'])) + expect(mock.modeOf('s/declared')).toBe('stream') + }) + + it('waits for opens with opened(), and answers $events with one ready frame per generation', async () => { + const mock = RemoteMock.create({ host: { home: '/home/me' } }) + const second = mock.streams.opened('$events', 2) + const first = mock.open('$events', [{}], idle()) + await expect(take(first, 1)).resolves.toEqual([{ type: 'ready', clientId: 'mock-client-1', host: { home: '/home/me' } }]) + const again = mock.open('$events', [{}], idle()) + await expect(second).resolves.toBeUndefined() + await expect(mock.streams.opened('$events', 1)).resolves.toBeUndefined() + await expect(take(again, 1)).resolves.toEqual([{ type: 'ready', clientId: 'mock-client-2', host: { home: '/home/me' } }]) + expect(RemoteMock.create().modeOf('$events')).toBe('stream') + }) +}) diff --git a/packages/test-support/remote-mock/tests/unary.client.spec.ts b/packages/test-support/remote-mock/tests/unary.client.spec.ts new file mode 100644 index 0000000000..7957689c96 --- /dev/null +++ b/packages/test-support/remote-mock/tests/unary.client.spec.ts @@ -0,0 +1,107 @@ +/** Registered unary defaults and the carrier's request and outcome log. */ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { RemoteMock, ok, openStream, type RemoteTable, type UnaryRuleFn } from '../src/index.ts' + +describe('RemoteMock unary answers', () => { + it('answers a fixed value verbatim and logs args and result with a global seq', async () => { + const mock = RemoteMock.create().unary('session/list', ok({ items: [] })) + await expect(mock.dispatch('session/list', [{}])).resolves.toEqual({ ok: true, value: { items: [] } }) + expect(mock.log.calls()).toEqual([{ endpoint: 'session/list', args: [{}], state: 'answered', result: { ok: true, value: { items: [] } }, seq: 1 }]) + expect(mock.log.calls('other/x')).toEqual([]) + expect(mock.modeOf('session/list')).toBe('unary') + expect(mock.modeOf('other/x')).toBeUndefined() + expect(mock.endpoints()).toEqual(['$events', 'session/list']) + }) + + it('replaces defaults on registration and table reload, including an explicit undefined answer', async () => { + const table: RemoteTable = { unary: { 'a/b': 'table' } } + const mock = RemoteMock.create().load(table) + await expect(mock.dispatch('a/b', [])).resolves.toBe('table') + mock.unary('a/b', 'replacement') + await expect(mock.dispatch('a/b', [])).resolves.toBe('replacement') + mock.load(table) + await expect(mock.dispatch('a/b', [])).resolves.toBe('table') + mock.load({ unary: { 'a/b': undefined } }) + await expect(mock.dispatch('a/b', [])).resolves.toBeUndefined() + expect(mock.modeOf('a/b')).toBe('unary') + expect(mock.endpoints()).toEqual(['$events', 'a/b']) + mock.assertNoUnmatched() + }) + + it('passes positional arguments to native spies and leaves scenario state with the handler', async () => { + let n = 0 + const handler = vi.fn((request: { id: number }, extra?: string) => { + expectTypeOf(request).toEqualTypeOf<{ id: number }>() + expectTypeOf(extra).toEqualTypeOf() + return ok(++n) + }) + const mock = RemoteMock.create() + .unary('a/b', handler) + .unary('a/c', () => Promise.resolve(n)) + await expect(mock.dispatch('a/b', [{ id: 1 }])).resolves.toEqual(ok(1)) + await expect(mock.dispatch('a/b', [{ id: 2 }, 'more'])).resolves.toEqual(ok(2)) + await expect(mock.dispatch('a/c', [])).resolves.toBe(2) + expect(handler).toHaveBeenCalledTimes(2) + expect(handler).toHaveBeenNthCalledWith(1, { id: 1 }) + expect(handler).toHaveBeenNthCalledWith(2, { id: 2 }, 'more') + }) + + it('keeps raw answers and typed handlers in tables without wrapping their arguments', async () => { + const handler: UnaryRuleFn<[namespace: string, value: number]> = (namespace, value) => ok({ namespace, value }) + const mock = RemoteMock.create().load({ unary: { 'a/read': handler, 'a/void': undefined, 'a/null': null } }) + await expect(mock.dispatch('a/read', ['settings', 2])).resolves.toEqual(ok({ namespace: 'settings', value: 2 })) + await expect(mock.dispatch('a/void', [])).resolves.toBeUndefined() + await expect(mock.dispatch('a/null', [])).resolves.toBeNull() + mock.unary('a/inferred', (value) => { + expectTypeOf(value).toEqualTypeOf() + return value + }) + await expect(mock.dispatch('a/inferred', [3])).resolves.toBe(3) + }) + + it('logs a call when its rule is selected and settles it with the rule: pending, answered, or failed', async () => { + const gate = Promise.withResolvers() + const mock = RemoteMock.create() + .unary('a/slow', () => gate.promise) + .unary('a/throws', () => { throw new Error('sync boom') }) + .unary('a/rejects', () => Promise.reject(new Error('async boom'))) + const slow = mock.dispatch('a/slow', [1]) + expect(mock.log.calls('a/slow')).toEqual([{ endpoint: 'a/slow', args: [1], state: 'pending', result: undefined, seq: 1 }]) + gate.resolve('late') + await expect(slow).resolves.toBe('late') + expect(mock.log.calls('a/slow')[0]).toMatchObject({ state: 'answered', result: 'late' }) + await expect(mock.dispatch('a/throws', [])).rejects.toThrow('sync boom') + await expect(mock.dispatch('a/rejects', [])).rejects.toThrow('async boom') + expect(mock.log.calls().map(call => [call.endpoint, call.state, call.result instanceof Error ? call.result.message : call.result])) + .toEqual([['a/slow', 'answered', 'late'], ['a/throws', 'failed', 'sync boom'], ['a/rejects', 'failed', 'async boom']]) + expect(mock.log.unmatched()).toEqual([]) + }) + + it('requests() lists the first arg of calls and opens in order, per endpoint or across all but the Gateway\'s own', async () => { + const mock = RemoteMock.create() + .unary('session/page', ok(null)) + .stream('session/follow', openStream([])) + const signal = new AbortController().signal + await mock.dispatch('session/page', [{ throughSeq: 5 }]) + mock.open('session/follow', [{ address: 'a' }], signal) + mock.open('$events', [{}], signal) + await mock.dispatch('session/page', [{ throughSeq: 9 }, 'extra']) + expect((mock.log.requests('session/page') as { throughSeq: number }[]).map(request => request.throughSeq)).toEqual([5, 9]) + expect(mock.log.requests('session/follow')).toEqual([{ address: 'a' }]) + expect(mock.log.requests('$events')).toEqual([{}]) + expect(mock.log.requests()).toEqual([{ throughSeq: 5 }, { address: 'a' }, { throughSeq: 9 }]) + expect(RemoteMock.create().log.requests()).toEqual([]) + }) + + it('loads a whole table', () => { + const mock = RemoteMock.create().load({ + unary: { 'session/list': ok({ items: [] }), 'a/b': 1 }, + stream: { 'session/control': openStream([{ type: 'baseline' }]) }, + }) + expect(mock.modeOf('session/list')).toBe('unary') + expect(mock.modeOf('a/b')).toBe('unary') + expect(mock.modeOf('session/control')).toBe('stream') + expect(() => RemoteMock.create().load({})).not.toThrow() + RemoteMock.create().assertNoUnmatched() + }) +}) diff --git a/packages/test-support/remote-mock/tsconfig.json b/packages/test-support/remote-mock/tsconfig.json new file mode 100644 index 0000000000..1ddb9b70e5 --- /dev/null +++ b/packages/test-support/remote-mock/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../typert/protocol" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../client/connection/tsconfig.client.json" + } + ] +} diff --git a/packages/test-support/remote-mock/tsdown.config.ts b/packages/test-support/remote-mock/tsdown.config.ts new file mode 100644 index 0000000000..1fe5837704 --- /dev/null +++ b/packages/test-support/remote-mock/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../../client/tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-remote-mock', + ['lib/types/index.js'], +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c019c2a7b..a5bafb0d2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10113,6 +10113,28 @@ importers: specifier: workspace:^ version: link:../../core/session + packages/test-support/remote-mock: + dependencies: + '@vitest/spy': + specifier: ^4.1.8 + version: 4.1.8 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-settings-controller': + specifier: workspace:^ + version: link:../../api/settings-controller + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/test-support/session-snapshot: dependencies: '@agentclientprotocol/sdk': diff --git a/tsconfig.base.json b/tsconfig.base.json index 83cabd9e4a..6359e07899 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -336,6 +336,7 @@ "@deepseek-ai/dsh-plan-mode/invariant": ["./packages/plan/plan-mode/src/invariant.ts"], "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"], "@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"], + "@deepseek-ai/dsh-remote-mock": ["./packages/test-support/remote-mock/src"], "@deepseek-ai/dsh-repeat-tool-reminder": ["./packages/guard/repeat-tool-reminder/src"], "@deepseek-ai/dsh-sandbox": ["./packages/sandbox/sandbox/src"], "@deepseek-ai/dsh-sandbox-local": ["./packages/sandbox/sandbox-local/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8ffb94127a..494e4f762f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -68,6 +68,7 @@ { "path": "./packages/extensions/cordis-client-runner" }, { "path": "./packages/extensions/ui-cordis" }, { "path": "./packages/test-support/client-runtime" }, + { "path": "./packages/test-support/remote-mock" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-brand-official" }, From 48d83eca66b1b5ca0eaa3af896d36a32371b9e27 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:19:09 +0800 Subject: [PATCH 15/19] refactor(client): share boot and transport integration points --- packages/api/gateway/src/client/index.ts | 17 ++- packages/api/session-controller/package.json | 3 +- .../client/connection/src/client/index.ts | 14 +- .../tests/client-apply.client.spec.ts | 18 ++- packages/client/hmr/package.json | 4 +- packages/client/hmr/src/client/index.ts | 31 ++-- packages/client/modules/src/client/index.ts | 2 +- .../client/modules/src/client/manifest.ts | 46 ++++++ packages/client/modules/src/index.ts | 35 +---- packages/client/web/src/boot-client.ts | 83 +++++++++++ packages/client/web/src/boot.ts | 75 ++-------- packages/client/web/src/mount.ts | 24 ++++ .../web/tests/boot-client.client.spec.ts | 133 ++++++++++++++++++ .../client/web/tests/mount.client.spec.ts | 47 +++++++ 14 files changed, 414 insertions(+), 118 deletions(-) create mode 100644 packages/client/web/src/boot-client.ts create mode 100644 packages/client/web/src/mount.ts create mode 100644 packages/client/web/tests/boot-client.client.spec.ts create mode 100644 packages/client/web/tests/mount.client.spec.ts diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 853fc4242f..edb58d3d25 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -728,11 +728,24 @@ function withdrawn(endpoint: string): Extract, { readonly ok return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) } -function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { +/** + * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and + * the thrown message. Exported so a stand-in for this face folds identically. + * @param endpoint - `/` that was called. + * @param error - what the carrier threw. + * @returns the failed result. + */ +export function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) } -function cancelledFailure(endpoint: string, cause: unknown): Extract, { readonly ok: false }> { +/** + * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`. + * @param endpoint - `/` that was called. + * @param cause - what the carrier threw when the signal aborted. + * @returns the failed result. + */ +export function cancelledFailure(endpoint: string, cause: unknown): Extract, { readonly ok: false }> { return { ok: false, error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }), diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 10362cdc61..de77c242f3 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -47,7 +47,8 @@ "@deepseek-ai/dsh-api-gateway/client" ], "inject": [ - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-file-upload" ], "platform": "web" } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 24deda96af..590004ef46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -78,9 +78,15 @@ export const inject: string[] = [] * provides both halves here instead of forking this plugin. */ export interface ClientTransportHooks { - /** Transport for generic unary RPC channels (the Typert gateway). */ - fetch: RpcFetch - /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ + /** + * Already decoded logical RPC carrier. When present it replaces the HTTP + * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process + * Host such as a test mock plugs in here). + */ + rpc?: ClientConnectionRpc + /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */ + fetch?: RpcFetch + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */ openStream?: RpcStreamOpen /** * Bundle transport for the module system, present when the carrier also owns @@ -185,7 +191,7 @@ export function apply(ctx: Context): void { const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__) - const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 60494dab57..3025767fd5 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -6,8 +6,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, + type ClientConnectionRpc, type ClientTransportHooks, type ConnectionGenerationSource, + type RpcFetch, type ConnectionHandle, type ConnectionState, } from '../src/client/index.ts' @@ -483,6 +485,20 @@ describe('connection client apply', () => { }) }) + it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => { + ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + const rpc: ClientConnectionRpc = { + call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })), + open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()), + } + ;(globalThis as Win).__DSH_TRANSPORT__ = { rpc } + const handle = await mount() + expect(handle.rpc).toBe(rpc) + await expect(handle.rpc.call('/api', 'session/list', { args: [] })).resolves.toEqual({ + ok: true, value: { endpoint: 'session/list', payload: { args: [] } }, + }) + }) + it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } const openStream = vi.fn>( @@ -492,7 +508,7 @@ describe('connection client apply', () => { })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { - fetch: vi.fn(), + fetch: vi.fn(), openStream, ownsHost: true, } diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 01866dac7a..282d6cc3e8 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -31,7 +31,9 @@ }, "dsh": { "client": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-modules" + ], "platform": "web", "immediately": true } diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index 66b0e9ad6d..eed2148265 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -75,6 +75,24 @@ export const name = 'client-hmr' /** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */ export const inject = ['loader', 'modules'] +/** + * Registry-first teardown of an entry's running fiber so `entry.refresh()` + * rebuilds it (see the module comment): delete the runtime record before the + * fiber's disposer emits `internal/plugin` (or the Loader flags the entry + * disabled), drain the unload so effect disposers finish before a new apply + * re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of + * no-oping. A fiberless entry is left untouched. + * @param entry - the Loader entry to tear down. + */ +export async function tearDownEntryFiber(entry: Entry): Promise { + const oldFiber = entry.fiber + if (oldFiber === undefined) return + const runtime = oldFiber.runtime + if (runtime !== null) entry.ctx.registry.delete(runtime.callback) + while (oldFiber.inertia !== undefined) await oldFiber.inertia + delete entry.fiber +} + /** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */ function findEntry(loader: Loader, id: string): Entry | undefined { for (const entry of loader.entries()) { @@ -115,18 +133,7 @@ export function apply(ctx: Context): void { modLoader.invalidate(id, rev) await modLoader.prefetch(id) - const oldFiber = entry.fiber - if (oldFiber !== undefined) { - // Registry-first teardown (see module comment): the runtime record must - // be gone before the fiber's disposer emits internal/plugin, or the - // Loader flags the entry disabled. - const runtime = oldFiber.runtime - if (runtime !== null) entry.ctx.registry.delete(runtime.callback) - // Drain the unload: effect disposers (slots, subscriptions) must finish - // before the new bundle executes and the new apply re-registers. - while (oldFiber.inertia !== undefined) await oldFiber.inertia - delete entry.fiber - } + await tearDownEntryFiber(entry) // Old owned styles go before materialization re-injects them (the CSS // idempotency guard keys on stable tag ids). removeOwnedStyles(id) diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index c2c6a604c8..d7bf39e518 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -18,7 +18,7 @@ import type { } from './manifest.ts' export { ClientModuleSystem } -export { parseBootManifest, stripClientSuffix } from './manifest.ts' +export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts' export type { BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration, ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord, diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index e8feb2ee15..d6f8f99b8f 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -30,6 +30,7 @@ */ import type {} from '@deepseek-ai/cordis' +import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest' import type { ClientModuleSystem } from './system.ts' declare module '@deepseek-ai/cordis' { @@ -144,6 +145,51 @@ export function optionalStringArray(subject: string, field: string, value: unkno return value as string[] } +/** + * Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared + * by the node half's Loader scan and the roster generator, so both read a + * package's browser declaration through one validator. + * @param pkgName - package name used as the diagnostic prefix. + * @param value - the raw `dsh.client` field of the package manifest. + * @returns the validated declaration, or undefined when the field is absent. + * @throws {Error} when the field is present but any member is malformed. + */ +export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined { + if (value === undefined) return undefined + if (typeof value !== 'object' || value === null) { + throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`) + } + const decl = value as Record + if (typeof decl.platform !== 'string') { + throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`) + } + const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject) + const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external) + if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { + throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`) + } + return { + platform: decl.platform, + ...(inject !== undefined ? { inject } : {}), + ...(external !== undefined ? { external } : {}), + ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), + } +} + +/** + * The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified + * specifier (`cordis:` builtins, `node:` modules, URLs). + * @param specifier - Loader row name. + * @returns the package name, or undefined. + */ +export function exactPackageSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('@')) { + const parts = specifier.split('/') + return parts.length === 2 && parts.every(Boolean) ? specifier : undefined + } + return specifier.length > 0 && !specifier.includes('/') && !specifier.includes(':') ? specifier : undefined +} + /** * Normalize a module specifier onto the graph row that owns it: a plugin bundle * IS its package's client half, so `/client` (the exports subpath external diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index c823657be5..36ff79941b 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -33,8 +33,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { Entry } from '@deepseek-ai/cordis-plugin-loader' import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' -import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest' -import { optionalStringArray, stripClientSuffix } from './client/manifest.ts' +import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts' import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts' export { stripClientSuffix } from './client/manifest.ts' @@ -173,38 +172,6 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$ /** Debugger source name appended to page bundles in the WebWorker image. */ const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/ -/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */ -function exactPackageSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('@')) { - const parts = specifier.split('/') - return parts.length === 2 && parts.every(Boolean) ? specifier : undefined - } - return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined -} - -/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */ -function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined { - if (value === undefined) return undefined - if (typeof value !== 'object' || value === null) { - throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`) - } - const decl = value as Record - if (typeof decl.platform !== 'string') { - throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`) - } - const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject) - const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external) - if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { - throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`) - } - return { - platform: decl.platform, - ...(inject !== undefined ? { inject } : {}), - ...(external !== undefined ? { external } : {}), - ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), - } -} - /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */ function clientExportOf(pkgName: string, exportsField: unknown): string | undefined { if (typeof exportsField !== 'object' || exportsField === null) return undefined diff --git a/packages/client/web/src/boot-client.ts b/packages/client/web/src/boot-client.ts new file mode 100644 index 0000000000..1275c47c55 --- /dev/null +++ b/packages/client/web/src/boot-client.ts @@ -0,0 +1,83 @@ +/** + * Production client composition without the page: mount the Loader over a + * module system, create every manifest row, wait for quiescence, and audit + * activation. `AppWebEntry` and the whole-client test carrier both call it. + * @module @deepseek-ai/dsh-client-web/src/boot-client + */ +import type { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client' +import { STATE_LABELS } from './loader-status.ts' + +/** Entry state label as the boot page renders it. */ +export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed' + +/** Inputs of {@link bootClient}. */ +export interface ClientBootOptions { + /** Fresh root Context that will own the plugin tree. */ + readonly ctx: Context + /** Module system installed as `loader.internal`. */ + readonly modules: ClientModuleLoader + /** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */ + readonly manifest: BootManifest + /** Per-entry state reporting (the boot page); omitted when no one renders progress. */ + readonly onEntryState?: (name: string, state: EntryStateLabel) => void +} + +/** + * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one + * `loader.create({ name })` per manifest row, `loader.await()`, then + * {@link assertEntriesActive}. A row whose module cannot be imported rejects + * `loader.create`, so that import error propagates from here as-is. + * @param options - context, module system, manifest, optional progress sink. + * @returns resolves after every entry is active; rejects with the audit report otherwise. + */ +export async function bootClient(options: ClientBootOptions): Promise { + const { ctx, manifest, onEntryState } = options + await ctx.plugin(Loader) + const loader = ctx.loader + loader.internal = options.modules as never + + ctx.on('internal/status', (fiber) => { + const entry = fiber.entry + if (entry === undefined || entry.fiber === undefined) return + onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state]) + }) + + const rows = manifest.plugins.map(row => row.id) + await Promise.all(rows.map(async (name) => { + onEntryState?.(name, 'loading') + const id = await loader.create({ name }) + if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed') + })) + + await loader.await() + assertEntriesActive(ctx) +} + +/** + * Reject entries that failed import/apply or still wait on missing services. + * @param ctx - root Context carrying the Loader. + * @throws {Error} listing every non-active entry with its reason. + */ +export function assertEntriesActive(ctx: Context): void { + const failures: string[] = [] + for (const entry of ctx.loader.entries()) { + const name = entry.options.name + if (entry.fiber === undefined) { + failures.push(`${name}: import failed (see console for the import error)`) + continue + } + const state = STATE_LABELS[entry.fiber.state] + if (state === 'active') continue + if (state === 'pending') { + const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) + failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + } else { + failures.push(`${name}: ${state}`) + } + } + if (failures.length > 0) { + throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + } +} diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 71ab65c140..84b2d5bdac 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -1,18 +1,18 @@ /** * Web boot kernel. It owns only the module system, Cordis loader, and a - * framework-free boot page. The dynamic UI renderer receives the mount + * framework-free boot page; plugin composition and the renderer handoff are + * `bootClient` and `mountClient`. The dynamic UI renderer receives the mount * point after every client entry activates. * @module @deepseek-ai/dsh-client-web/src/boot */ import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' import type { BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow, } from '@deepseek-ai/dsh-client-modules/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { bootClient } from './boot-client.ts' import { BootPage } from './boot-page.ts' +import { mountClient } from './mount.ts' import { getStaticModules } from './seed.ts' -import { STATE_LABELS } from './loader-status.ts' import './base.css' /** Module transport hook replaced by jsdom tests. */ @@ -76,8 +76,15 @@ export class AppWebEntry { const prefetching = this.prefetchImmediateTier() const ctx = new Context() this.ctx = ctx - await this.runPluginBoot(ctx, prefetching) - await this.mountApp(ctx) + this.page.setTotal(this.manifest.plugins.length) + await prefetching + await bootClient({ + ctx, + modules: this.modules, + manifest: this.manifest, + onEntryState: (name, state) => { this.page.setState(name, state) }, + }) + await mountClient(ctx, this.container) } catch (reason) { console.error(reason) this.page.fail(reason instanceof Error ? reason.message : String(reason)) @@ -92,14 +99,6 @@ export class AppWebEntry { this.page.dispose() } - /** Mount through a dependency fiber so replacing uiRenderer remounts the application. */ - private async mountApp(ctx: Context): Promise { - const mounted = ctx.inject(['uiRenderer'], (scope) => { - scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount') - }) - await mounted - } - /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */ private async prefetchImmediateTier(): Promise { await Promise.all(this.manifest.plugins @@ -108,52 +107,4 @@ export class AppWebEntry { // Prefetch only starts transport early; the Loader import retries and reports this bundle failure. }))) } - - /** Mount the Loader, create all graph entries, await quiescence, and audit activation. */ - private async runPluginBoot(ctx: Context, prefetching: Promise): Promise { - await ctx.plugin(Loader) - const loader = ctx.loader - loader.internal = this.modules as never - - ctx.on('internal/status', (fiber) => { - const entry = fiber.entry - if (entry === undefined || entry.fiber === undefined) return - this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state]) - }) - - const rows = this.manifest.plugins.map(row => row.id) - this.page.setTotal(rows.length) - await prefetching - await Promise.all(rows.map(async (name) => { - this.page.setState(name, 'loading') - const id = await loader.create({ name }) - if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed') - })) - - await loader.await() - this.assertEntriesActive(ctx) - } - - /** Reject entries that failed import/apply or still wait on missing services. */ - private assertEntriesActive(ctx: Context): void { - const failures: string[] = [] - for (const entry of ctx.loader.entries()) { - const name = entry.options.name - if (entry.fiber === undefined) { - failures.push(`${name}: import failed (see console for the import error)`) - continue - } - const state = STATE_LABELS[entry.fiber.state] - if (state === 'active') continue - if (state === 'pending') { - const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) - failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) - } else { - failures.push(`${name}: ${state}`) - } - } - if (failures.length > 0) { - throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) - } - } } diff --git a/packages/client/web/src/mount.ts b/packages/client/web/src/mount.ts new file mode 100644 index 0000000000..4836a92a6d --- /dev/null +++ b/packages/client/web/src/mount.ts @@ -0,0 +1,24 @@ +/** + * Application mount through a dependency fiber, so replacing `uiRenderer` + * remounts the application. Shared by `AppWebEntry` and the test carrier. + * @module @deepseek-ai/dsh-client-web/src/mount + */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' + +/** + * Mount the UI renderer into `container` through a dependency fiber on + * `uiRenderer`: the mount effect installs when the service is provided and + * reinstalls when it is replaced. + * @param ctx - booted root Context. + * @param container - application mount point. + * @returns resolves once the dependency fiber exists; with `uiRenderer` + * already provided (as after `bootClient`) the mount effect is installed by + * then, otherwise it installs when the service arrives. + */ +export async function mountClient(ctx: Context, container: HTMLElement): Promise { + const mounted = ctx.inject(['uiRenderer'], (scope) => { + scope.effect(() => scope.uiRenderer.mount(container), 'web boot: application mount') + }) + await mounted +} diff --git a/packages/client/web/tests/boot-client.client.spec.ts b/packages/client/web/tests/boot-client.client.spec.ts new file mode 100644 index 0000000000..478819ca59 --- /dev/null +++ b/packages/client/web/tests/boot-client.client.spec.ts @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { + createClientModuleSystem, parseBootManifest, + type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph, +} from '@deepseek-ai/dsh-client-modules/client' +import { describe, expect, it } from 'vitest' +import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts' +import { FIBER_STATE } from '../src/loader-status.ts' + +const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules' + +function graphOf(ids: readonly string[]): WebBootGraph { + const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' })) + return { + rev: 'graph', + entries, + batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }], + } +} + +/** Module system seeded with inline plugin modules; `loaded` records every transport call. */ +function modulesOf(graph: WebBootGraph, staticModules: Record): { modules: ClientModuleLoader; loaded: string[] } { + const loaded: string[] = [] + const pendingQueue: ClientBundleRegistration[] = [] + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue, + load: (registration) => { pendingQueue.push(registration) }, + create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options), + } + const modules = target.create({ + boot: graph, + staticModules, + loadBundle: async (url) => { loaded.push(url) }, + }) + return { modules, loaded } +} + +/** Recording progress sink. */ +function stateSink(): { states: Map; onEntryState: (name: string, state: EntryStateLabel) => void } { + const states = new Map() + return { + states, + onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) }, + } +} + +describe('bootClient', () => { + it('activates every seeded row without touching the bundle transport', async () => { + const graph = graphOf(['provider', 'consumer']) + const { modules, loaded } = modulesOf(graph, { + provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } }, + consumer: { inject: ['x'], apply: () => {} }, + }) + const ctx = new Context() + const sink = stateSink() + + await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState }) + + expect(loaded).toEqual([]) + const consumer = sink.states.get('consumer') ?? [] + expect(consumer[0]).toBe('loading') + expect(consumer.at(-1)).toBe('active') + expect(sink.states.get('provider')?.at(-1)).toBe('active') + await ctx.fiber.dispose() + }) + + it('reports a row waiting on a service the roster never provides', async () => { + const graph = graphOf(['orphan']) + const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } }) + const ctx = new Context() + + await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow( + 'orphan: pending (waiting for service: nothing)', + ) + await ctx.fiber.dispose() + }) + + it('surfaces the Loader import error for a row that is neither seeded nor a graph row', async () => { + const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } }) + const manifest = parseBootManifest(graphOf(['ghost'])) + const ctx = new Context() + const sink = stateSink() + + await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow( + /failed to import loader entry \S+ \(ghost\): client-modules: cannot resolve/, + ) + expect(sink.states.get('ghost')).toEqual(['loading']) + await ctx.fiber.dispose() + }) +}) + +describe('assertEntriesActive', () => { + interface FakeEntry { name: string; fiber?: { state: number; inject: Record } } + + /** Loader-shaped double: entries with scripted fiber states, services by name. */ + function auditCtx(entries: readonly FakeEntry[], services: Record = {}): Context { + return { + loader: { + * entries() { + for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber } + }, + }, + get: (name: string) => services[name], + } as unknown as Context + } + + it('passes when every entry is active', () => { + expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow() + }) + + it('names import failures, missing services, and other non-active states', () => { + const ctx = auditCtx([ + { name: 'lost' }, + { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } }, + { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } }, + { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } }, + ], { present: {} }) + + expect(() => { assertEntriesActive(ctx) }).toThrow([ + 'web boot: 4 entries did not activate', + 'lost: import failed (see console for the import error)', + 'waiting: pending (waiting for services: a, b)', + 'opaque: pending (waiting for services: unknown)', + 'broken: failed', + ].join('\n')) + }) + + it('uses the singular form for one failing entry', () => { + expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n') + }) +}) diff --git a/packages/client/web/tests/mount.client.spec.ts b/packages/client/web/tests/mount.client.spec.ts new file mode 100644 index 0000000000..fbeb5635bd --- /dev/null +++ b/packages/client/web/tests/mount.client.spec.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { mountClient } from '../src/mount.ts' + +/** Provide a fake `uiRenderer` from its own plugin fiber so it can be replaced. */ +function provideRenderer(ctx: Context, mount: (container: HTMLElement) => () => void) { + return ctx.plugin({ apply: (scope: Context) => { scope.reflect.provide('uiRenderer', { mount }) } }) +} + +describe('mountClient', () => { + it('mounts into the container and unmounts when the tree is disposed', async () => { + const ctx = new Context() + const unmount = vi.fn() + const mount = vi.fn((_container: HTMLElement) => unmount) + provideRenderer(ctx, mount) + const container = document.createElement('div') + + await mountClient(ctx, container) + + expect(mount).toHaveBeenCalledExactlyOnceWith(container) + expect(unmount).not.toHaveBeenCalled() + await ctx.fiber.dispose() + expect(unmount).toHaveBeenCalledOnce() + }) + + it('remounts when uiRenderer is replaced', async () => { + const ctx = new Context() + const container = document.createElement('div') + const first = { unmount: vi.fn(), mount: vi.fn(() => first.unmount) } + const second = { unmount: vi.fn(), mount: vi.fn(() => second.unmount) } + + await mountClient(ctx, container) + expect(first.mount).not.toHaveBeenCalled() + + const renderer = provideRenderer(ctx, first.mount) + await vi.waitFor(() => { expect(first.mount).toHaveBeenCalledExactlyOnceWith(container) }) + + await renderer.dispose() + expect(first.unmount).toHaveBeenCalledOnce() + + provideRenderer(ctx, second.mount) + await vi.waitFor(() => { expect(second.mount).toHaveBeenCalledExactlyOnceWith(container) }) + await ctx.fiber.dispose() + expect(second.unmount).toHaveBeenCalledOnce() + }) +}) From 627c0f741e854e6f39b68d6f8fe8ed8bbc585388 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:20:47 +0800 Subject: [PATCH 16/19] feat(test-support): assemble real clients with native test fixtures --- .../client/ui-permission-presets/package.json | 1 + .../tests/browser-plugin.client.spec.ts | 11 +- .../client/ui-settings-models/package.json | 1 + .../tests/apply.client.spec.ts | 75 ++-- .../client-runtime/README.i18n.yaml | 4 +- .../test-support/client-runtime/README.md | 8 +- .../test-support/client-runtime/README.zh.md | 8 +- .../test-support/client-runtime/package.json | 27 +- .../src/assembly/bundle-roster.ts | 138 +++++++ .../client-runtime/src/assembly/index.ts | 14 + .../client-runtime/src/assembly/modules.ts | 80 ++++ .../src/assembly/remote-default-responses.ts | 45 +++ .../src/assembly/remote-proxies.ts | 87 +++++ .../client-runtime/src/assembly/roster.ts | 163 ++++++++ .../src/assembly/test-client.ts | 361 ++++++++++++++++++ .../client-runtime/src/assembly/vitest.ts | 60 +++ .../test-support/client-runtime/src/index.ts | 2 - .../client-runtime/src/settings-remote.ts | 85 ----- .../assembly-bundle-roster.client.spec.ts | 195 ++++++++++ .../assembly-dependencies.client.spec.ts | 37 ++ .../tests/assembly-modules.client.spec.ts | 105 +++++ .../tests/assembly-plan.client.spec.ts | 22 ++ .../assembly-remote-proxies.client.spec.ts | 93 +++++ .../tests/assembly-roster.client.spec.ts | 112 ++++++ .../assembly-test-client-node.client.spec.ts | 110 ++++++ .../tests/assembly-test-client.client.spec.ts | 178 +++++++++ .../tests/assembly-vitest.client.spec.ts | 64 ++++ .../tests/remote.client.spec.ts | 37 -- .../test-support/client-runtime/tsconfig.json | 27 +- pnpm-lock.yaml | 48 +++ tsconfig.base.json | 3 + 31 files changed, 2010 insertions(+), 191 deletions(-) create mode 100644 packages/test-support/client-runtime/src/assembly/bundle-roster.ts create mode 100644 packages/test-support/client-runtime/src/assembly/index.ts create mode 100644 packages/test-support/client-runtime/src/assembly/modules.ts create mode 100644 packages/test-support/client-runtime/src/assembly/remote-default-responses.ts create mode 100644 packages/test-support/client-runtime/src/assembly/remote-proxies.ts create mode 100644 packages/test-support/client-runtime/src/assembly/roster.ts create mode 100644 packages/test-support/client-runtime/src/assembly/test-client.ts create mode 100644 packages/test-support/client-runtime/src/assembly/vitest.ts delete mode 100644 packages/test-support/client-runtime/src/settings-remote.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts create mode 100644 packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index a80a2e5563..3903e578d6 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index f802b93b2d..b99b2e2ddd 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -9,11 +9,13 @@ * its Settings row and invalidates that row on host settings changes. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import type { CommandDecoration, PopupSelectSpec } from '@deepseek-ai/dsh-client-ui-commands/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission-presets/client' @@ -40,8 +42,9 @@ async function bench() { const locale = new LocaleRuntime(ctx) locale.setLocale('en') ctx.provide('locale', locale) - const settingsRemote = scriptedSettingsRemote() - const remote = new TestRemote(ctx, { settings: settingsRemote.settings }) + const mock = RemoteMock.create().load(remoteDefaultResponses) + onTestFinished(() => { mock.assertNoUnmatched() }) + const remote = new TestRemote(ctx, { settings: mock.remote.settings }) ctx.slots.register({ name: 'root', children: { diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index b7272db74f..f45aa17d1b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 84a760e1f3..da1dd00f50 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -1,10 +1,12 @@ /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts' +import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { @@ -19,7 +21,8 @@ import { apply as hostApply } from '../src/index.ts' // so browser-language detection never runs and a fresh LocaleRuntime opens on // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. -async function bench(isLoopback = true, settings?: object, services: object = {}) { +async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) { + onTestFinished(() => { mock.assertNoUnmatched() }) const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -37,10 +40,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })), ...services, }, - // Without a settings face the mirror's reads fail and stay contained; the - // Models join itself never fetches until a section actually loads. The real - // ui-settings apply also provides the settingsSchema service. - settings: settings ?? scriptedSettingsRemote().settings, + settings: mock.remote.settings, }) // The fixed Host facts the settings provider reads its persistence from. remote.$host = { home: undefined, isLoopback } @@ -259,25 +259,18 @@ describe('pushed invalidations', () => { it('welcome state follows the shared mirror across document commits', async () => { // The welcome notice derives from its settings scope: a document commit // reaches it through the mirror's one refresh, with no routing here. - const acknowledgement = { current: undefined as string | undefined } - const settings = { - describe: vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, - applies: 'live' as const, - secrets: [], - revision: 0, - }], - }, - })), + const mock = RemoteMock.create().load(remoteDefaultResponses) + const namespace = { + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: {}, + applies: 'live' as const, + secrets: [], + revision: 0, } - const b = await bench(true, settings) + const document = { writable: true, hasDocument: false, namespaces: [namespace] } + mock.remote.settings.describe.mockResolvedValue(ok(document)) + const b = await bench(true, mock) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -290,7 +283,10 @@ describe('pushed invalidations', () => { await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) }) - acknowledgement.current = WELCOME_NOTICE_VERSION + mock.remote.settings.describe.mockResolvedValue(ok({ + ...document, + namespaces: [{ ...namespace, value: { [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, revision: 1 }], + })) b.remote.emit('settings/document-updated', ['ui-onboarding', 1]) await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) @@ -298,24 +294,13 @@ describe('pushed invalidations', () => { }) it('joins the refreshed mirror view on a settings invalidation', async () => { - let revision = 1 - const describe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: 'llm-test', - schema: {}, - value: {}, - applies: 'live' as const, - secrets: [], - revision, - }], - }, - })) + const mock = RemoteMock.create().load(remoteDefaultResponses) + const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 } + const document = { writable: true, hasDocument: false, namespaces: [namespace] } + const describe = mock.remote.settings.describe + describe.mockResolvedValue(ok(document)) const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) - const b = await bench(true, { describe }, { listProviders }) + const b = await bench(true, mock, { listProviders }) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.section') @@ -327,8 +312,8 @@ describe('pushed invalidations', () => { await injected.controller.load() expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) - revision = 2 - b.remote.emit('settings/document-updated', ['llm-test', revision]) + describe.mockResolvedValue(ok({ ...document, namespaces: [{ ...namespace, revision: 2 }] })) + b.remote.emit('settings/document-updated', ['llm-test', 2]) await vi.waitFor(() => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 74f763cef7..addb502c8a 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-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/test-support/client-runtime/README.md -README.md: 61426ccb8002032508feb3c9dbd26718d676177d -README.zh.md: e462f42c1d6ac7a3c7951e65ae23a424679057b0 +README.md: e32f7858b32e15e5200f9a986cb88e29309c44cb +README.zh.md: 1b7da6f38723607dedd5eb16a15ade0b974ed135 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 61426ccb80..e32f7858b3 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -85,15 +85,13 @@ test('registers into the sidebar', async ({ remote, start }) => { The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every namespace in both tiers. An assembled test uses the `remote` fixture; a local `TestRemote` can receive `{ settings: mock.remote.settings }`. Configure returned data and read native `.mock.calls` directly. A mutation response does not update later describe answers automatically: change `remote.settings.describe.mockResolvedValue(...)` explicitly when the scenario publishes new data. The proxy documentation owns unbuilt typing and the required build-backed local typecheck. -### Browser module loading - -A browser page imports `src/assembly/browser.ts`, supplies parsed `ClientRoster` rows, and passes `loadClientModule(name)` in `TestClient.start` options. The importer returns source module namespaces in that page's realm; `provide` replacements and the bootstrap module retain their priority. Node-side bundle reading and Vitest fixtures remain in `src/assembly/index.ts`. Function handlers, native mock queues, and promises are created in the realm where the mock runs, not sent through Playwright serialization. The [shared settings scenario](tests/assembly-settings-scenario.client.ts) runs in jsdom and in [two concurrent Chromium pages](../../../apps/web/tests/client-assembly.e2e.ts), using the web build's source aliases and definitions on independently allocated static servers without a business Host. Source assembly does not test published bundle loading or generated Remote clients. +### Roster and startup behavior `webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it -Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is browser-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. +Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is client-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. ### What can go wrong @@ -130,7 +128,6 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue | | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: transport install, jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | Test-scoped `mock` and lazy `start` fixtures | -| [`src/assembly/browser.ts`](src/assembly/browser.ts) | Realm-local startup without bundle-file reading or Vitest fixtures | | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`: default responses of the Remote endpoints the roster calls at boot | | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | Contract-free `remote.` proxies over the Connection: `remoteNamespacesOf`, `remoteProxiesPlugin` | | [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` and `webApp`: the browser roster read from bundle patch files with the include plugin's own schema and patch application | @@ -173,7 +170,6 @@ None; this package neither assembles nor sends a provider request. These limits define how the bench is consumed. They are current package constraints, not a task backlog. -- **Separate runner entries** — the slot tier and native fixtures use Vitest; browser assembly imports `src/assembly/browser.ts` and supplies a source importer. Neither tier starts a Host or validates HTTP/WebSocket encoding. - **The whole-client tier does not run the generated Remote clients** — `remote.` proxies forward positional arguments without the generated zod validation, wire-name mapping, or scoped-identity injection; mock handlers receive those arguments directly, and the generated clients stay covered by the built-artifact e2e lanes. - **Proxied calls bypass the Gateway client's `invoke` and `invokeStream`** — no `$mount` lifecycle check runs, stream failures are not re-marked through `normalizeConnectionStream`, and a unary rejection is folded by the proxy itself with the Gateway client's exported `carrierFailure` and `cancelledFailure`. `ctx.remote.$stream`, `$on`, and `$host` are the real Gateway client's. - **An undeclared endpoint is dispatched as a unary call** — the proxy learns each endpoint's mode from the mock's registrations; a stream endpoint the spec neither scripts nor declares (`RemoteTable.streams`, `mock.stream(endpoint)`) is logged as a `unary` miss and product code receives a folded result rather than a failing stream. `remoteDefaultResponses` declares the roster's later-opened streams; `dispose()` fails the test either way. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index e462f42c1d..1b7da6f387 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -85,15 +85,13 @@ test('registers into the sidebar', async ({ remote, start }) => { 两档测试的所有命名空间都使用[通用 Remote Proxy](../remote-mock/README.zh.md#remote-proxy)。装配测试使用 `remote` fixture;局部 `TestRemote` 可以接收 `{ settings: mock.remote.settings }`。直接配置返回数据,并读取原生 `.mock.calls`。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 `remote.settings.describe.mockResolvedValue(...)`。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。 -### 浏览器模块加载 - -浏览器页面导入 `src/assembly/browser.ts`,提供已解析的 `ClientRoster` 行,并在 `TestClient.start` 选项中传入 `loadClientModule(name)`。importer 在该页面 realm 内返回源码模块命名空间;`provide` 替换和 bootstrap 模块仍有原来的优先级。Node 侧 bundle 读取与 Vitest fixture 留在 `src/assembly/index.ts`。函数 handler、原生 mock 队列和 promise 在 mock 所在 realm 创建,不经过 Playwright 序列化。[共享 settings 场景](tests/assembly-settings-scenario.client.ts) 同时在 jsdom 和[两个并发 Chromium 页面](../../../apps/web/tests/client-assembly.e2e.ts)中运行,独立分配的静态服务器使用 web 构建的源码别名与定义,不启动业务 Host。源码装配不验证已发布 bundle 加载或生成的 Remote 客户端。 +### Roster 与启动行为 `webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 -当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是浏览器侧测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 +当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是客户端测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 ### 可能出什么问题 @@ -130,7 +128,6 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 | | [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:装传输、jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | | [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | 测试级 `mock` 与懒启动 `start` fixture | -| [`src/assembly/browser.ts`](src/assembly/browser.ts) | 不读取 bundle 文件、不引入 Vitest fixture 的 realm 内启动入口 | | [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`:roster 启动期 Remote 端点的默认响应 | | [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | 经 Connection 的无契约 `remote.` 代理:`remoteNamespacesOf`、`remoteProxiesPlugin` | | [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` 与 `webApp`:用 include 插件自己的 schema 与补丁应用从 bundle 补丁文件读出浏览器 roster | @@ -173,7 +170,6 @@ test('registers into the sidebar', async ({ remote, start }) => { 这些限制说明本测试台如何被消费。它们是当前包约束,不是任务积压。 -- **runner 入口分开**——slot 档与原生 fixture 使用 Vitest;浏览器装配导入 `src/assembly/browser.ts` 并提供源码 importer。两档都不启动 Host,也不验证 HTTP/WebSocket 编码。 - **整体档不运行生成的 Remote 客户端**——`remote.` 代理转发位置参数,不经过生成的 zod 校验、wire 名映射或 scoped 身份注入;mock handler 直接接收这些参数,生成客户端仍由 built-artifact e2e 车道覆盖。 - **代理调用绕过 Gateway 客户端的 `invoke` 与 `invokeStream`**——不做 `$mount` 生命周期检查,流失败不经 `normalizeConnectionStream` 重新标记,一元拒绝由代理自己用 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure` 折叠。`ctx.remote.$stream`、`$on`、`$host` 是真 Gateway 客户端的。 - **未声明的端点按一元调用发出**——代理从 mock 的登记学到每个端点的模式;spec 既没给脚本也没声明(`RemoteTable.streams`、`mock.stream(endpoint)`)的流端点记为 `unary` 漏配,产品代码收到的是折叠结果而不是失败的流。`remoteDefaultResponses` 声明了 roster 启动后才打开的流;无论哪种,`dispose()` 都会让测试失败。 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 44cd37bc91..e2b0996fde 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", - "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", + "description": "Browser test runtimes: a jsdom slot bench with test-owned Session and Workspace doubles, and a whole-client tier that boots the web roster through the production bootClient over an endpoint-named Remote mock", "version": "0.1.5-rc.1", "publishConfig": { "access": "public" @@ -25,13 +25,20 @@ "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "js-yaml": "^4.2.0", "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -39,18 +46,28 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -59,12 +76,16 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-web-app": "workspace:^", + "@types/js-yaml": "^4.0.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts new file mode 100644 index 0000000000..14284556f5 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -0,0 +1,138 @@ +/** + * The browser roster of a `dsh --profile`, read from its bundle patch files + * the way the launcher composes them: each bundle's `dsh.bundle.patch` list is + * parsed with the include plugin's YAML dialect (`entryListSchema`) and + * composed by its `applyEntryPatches`; every enabled row whose package + * declares `dsh.client.platform === 'web'` becomes a roster row carrying that + * declaration's `inject` and `immediately`; rows nested in Loader groups count + * like the Loader counts them, a disabled group disabling every row beneath + * it. A patch that matches nothing + * throws here where the launcher warns. Nothing is copied from the bundles: a + * bundle change is visible at the next import. Node only — the + * whole-client tier runs under vitest, and this is the one place it reads the + * repository. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/bundle-roster + */ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client' +import * as yaml from 'js-yaml' +import { ClientRoster, type ClientRosterRow } from './roster.ts' + +/** The `web` profile's bundle layers, in the order `dsh --profile web` applies them (app-boot `PROFILE_TEMPLATES.web`). */ +export const WEB_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] + +interface PackageManifest { + name?: unknown + dsh?: { bundle?: { patch?: unknown }; client?: unknown } +} + +/** One bundle: where its package.json is (plugin names resolve from there) and its parsed patch list. */ +interface BundleLayer { + readonly manifestPath: string + readonly patches: PatchOptions[] +} + +/** + * Compose the browser roster of `bundles`, applied in order. + * @param bundles - bundle package names in application order. + * @param anchor - file whose package resolution locates the bundles; default this package. + * @returns the roster in composition order, one row per package. + * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list + * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression. + */ +export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster { + const layers = bundles.map(name => readLayer(name, anchor)) + const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => { + throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`) + }) + const anchors = layers.map(layer => layer.manifestPath) + const rows: ClientRosterRow[] = [] + const seen = new Set() + for (const { entry, disabled } of flattenGroups(entries)) { + const name = exactPackageSpecifier(entry.name) + if (name === undefined || disabled === true || seen.has(name)) continue + seen.add(name) + const manifestPath = locateManifest(anchors, name) + if (manifestPath === undefined) { + throw new Error(`client-test-runtime: cannot resolve plugin package ${name} from ${bundles.join(', ')}`) + } + const manifest = readManifest(manifestPath) + if (manifest.name !== name) { + throw new Error(`client-test-runtime: ${manifestPath} names ${JSON.stringify(manifest.name)}, expected ${name}`) + } + const declaration = parseDshClient(name, manifest.dsh?.client) + if (declaration === undefined || declaration.platform !== 'web') continue + if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') { + throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`) + } + rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true }) + } + return ClientRoster.of(rows) +} + +function readLayer(bundle: string, anchor: string): BundleLayer { + const manifestPath = locateManifest([anchor], bundle) + if (manifestPath === undefined) throw new Error(`client-test-runtime: cannot resolve bundle ${bundle} from ${anchor}`) + const patch = readManifest(manifestPath).dsh?.bundle?.patch + if (typeof patch !== 'string') throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch in ${manifestPath}`) + const file = join(dirname(manifestPath), patch) + const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema }) + if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`) + return { manifestPath, patches: parsed as PatchOptions[] } +} + +/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */ +interface FlatEntry { + readonly entry: EntryOptions + readonly disabled: unknown +} + +/** + * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's + * `disabled` disables every row beneath it. + * @param entries - composed entries, possibly nested. + * @param inherited - the enclosing group's `disabled` when set. + * @returns the plugin rows. + */ +function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] { + const rows: FlatEntry[] = [] + for (const entry of entries) { + const own = (entry as { disabled?: unknown }).disabled + const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own + if (entry.group === true && Array.isArray(entry.config)) { + rows.push(...flattenGroups(entry.config as EntryOptions[], disabled)) + continue + } + rows.push({ entry, disabled }) + } + return rows +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +/** Locate `/package.json` on the resolution paths of any anchor, without requiring a `./package.json` export. */ +function locateManifest(anchors: readonly string[], name: string): string | undefined { + for (const anchor of anchors) { + for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) { + const candidate = join(searchPath, name, 'package.json') + if (existsSync(candidate)) return candidate + } + } + return undefined +} + +/** The include plugin's `%C` placeholders, filled the way the launcher prints them. */ +function describe(message: string, args: readonly unknown[]): string { + let index = 0 + return message.replace(/%C/g, () => JSON.stringify(args[index++])) +} + +/** The `web` profile's browser roster, composed from its bundles at import. */ +export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES) diff --git a/packages/test-support/client-runtime/src/assembly/index.ts b/packages/test-support/client-runtime/src/assembly/index.ts new file mode 100644 index 0000000000..5223e691b3 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/index.ts @@ -0,0 +1,14 @@ +/** + * Whole-client tier entry (deep import only: + * `@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts`). Kept out of + * the package root so slot-tier specs do not load the assembly machinery. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly + */ +export { ClientRoster } from './roster.ts' +export type { AssemblyPlan, ClientPluginModule, ClientRosterRow } from './roster.ts' +export { TestClient } from './test-client.ts' +export type { TestClientOptions } from './test-client.ts' +export { remoteDefaultResponses } from './remote-default-responses.ts' +export { bundleRoster, webApp } from './bundle-roster.ts' +export { createClientTest } from './vitest.ts' +export type { ClientTestFixtures } from './vitest.ts' diff --git a/packages/test-support/client-runtime/src/assembly/modules.ts b/packages/test-support/client-runtime/src/assembly/modules.ts new file mode 100644 index 0000000000..60a2fdbfdf --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/modules.ts @@ -0,0 +1,80 @@ +/** + * In-process module arrival: import every roster row's `/client` module (or + * the plan's replacement) and register each as a pre-arrived factory on a + * production `ClientModuleSystem`, so neither the Loader's `internal.import` + * nor a stage-one `prefetch` ever fetches a bundle. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/modules + */ +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import { createClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' +import type { ClientModuleLoader, ClientModuleLoaderTarget, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import type { AssemblyPlan, ClientPluginModule } from './roster.ts' + +/** The bootstrap row: always this process's static namespace, never a dynamic import or a `provide` replacement. */ +export const MODULES_PACKAGE = '@deepseek-ai/dsh-client-modules' + +/** + * Resolve each roster row to its plugin module: `plan.provide[name]` when + * present, otherwise a `/client` import resolved by the repository's tsconfig + * path aliases under Vitest. The bootstrap row is the + * statically imported `@deepseek-ai/dsh-client-modules/client` namespace. + * @param plan - validated plan. + * @returns package name → module, in roster order. + * @throws {Error} when an import fails (the package name prefixes the original message) or the bootstrap row is provided. + */ +export async function loadPluginModules(plan: AssemblyPlan): Promise> { + const modules = new Map() + for (const { name } of plan.roster.rows) { + const provided = plan.provide?.[name] + if (name === MODULES_PACKAGE) { + if (provided !== undefined) { + throw new Error(`client-test-runtime: ${MODULES_PACKAGE} is the bootstrap module and cannot be provided`) + } + modules.set(name, modulesClient) + continue + } + modules.set(name, provided ?? await importClient(name)) + } + return modules +} + +async function importClient(name: string): Promise { + let namespace: unknown + try { + namespace = await import(/* @vite-ignore */ `${name}/client`) + } catch (error) { + throw new Error(`client-test-runtime: cannot import ${name}/client: ${String(error)}`, { cause: error }) + } + return namespace as ClientPluginModule +} + +/** + * Build the production module system over queued factories returning the + * loaded namespaces. The bootstrap row uses `bootstrapModule`; `staticModules` + * is empty because namespaces already hold their own imports. Missing factories + * reject through `loadBundle` without fetching. + * @param graph - raw boot graph from `graphFromRoster`; `createClientModuleSystem` parses it. + * @param modules - loaded plugin modules keyed by package name. + * @returns module system to install as `loader.internal`; its `manifest` is the parsed graph. + */ +export function createInProcessModules( + graph: WebBootGraph, + modules: ReadonlyMap, +): ClientModuleLoader { + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue: [], + /* v8 ignore next -- construction replaces this sink before draining the prefilled queue. */ + load: () => { throw new Error('client-test-runtime: module facade is not initialized') }, + create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: modulesClient }, options), + } + for (const [id, namespace] of modules) { + if (id === MODULES_PACKAGE) continue + target.pendingQueue.push({ id, factory: () => namespace as unknown as Record }) + } + return target.create({ + boot: graph, + staticModules: {}, + loadBundle: url => Promise.reject(new Error(`client-test-runtime: in-process modules never load bundles (${url})`)), + }) +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts new file mode 100644 index 0000000000..2c0dfddbd1 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -0,0 +1,45 @@ +/** + * Default responses for every Remote endpoint the web assembly calls while + * booting and rendering with no sessions, no workspaces, and default settings. + * The comment above each row names the plugin that calls it; endpoints boot + * never touches stay absent so a new call fails loud. `$events` is built into + * `RemoteMock`. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses + */ +import { ok, openStream, type RemoteTable } from '@deepseek-ai/dsh-remote-mock' + +/** Default responses of the boot-time Remote endpoints; a spec loads it first and layers its own table on top. */ +export const remoteDefaultResponses: RemoteTable = { + unary: { + // api-session-controller `sessions.handleConnected()` on `connection/reset`. + 'session/list': ok({ items: [] }), + // ui-settings `mirror.ensure()` at apply and again on `connection/reset`. + 'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }), + // ui-model-selection `ModelDirectoryResolver` constructor. + 'session/modelCatalog': ok({ + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: [], + groups: [], + failures: [], + }), + // ui-agent-preset hero chip and header label on first mount. + 'agentPresets/list': ok({ presets: [], authorable: false }), + // cordis-client-runner `ClientCordisInspectRegistry.sync` at apply and on `connection/reset`. + 'dynamicCordisRunner/syncInspectManifest': ok(null), + // ui-cordis inventory at apply and on `connection/reset`. + 'dynamicCordisRunner/inventory': ok([]), + // ui-settings-plugins web-search card `readCredential()` when the settings mirror first publishes. + 'credentials/describe': ok({}), + }, + // Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss. + streams: [ + // api-session-controller `SessionEventStream.follow` when a Session opens. + 'session/follow', + ], + stream: { + // api-session-controller client `apply`: the control stream's opening baseline, then open. + 'session/control': openStream([{ type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }]), + // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. + 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]), + }, +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-proxies.ts b/packages/test-support/client-runtime/src/assembly/remote-proxies.ts new file mode 100644 index 0000000000..d1a3b09142 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/remote-proxies.ts @@ -0,0 +1,87 @@ +/** + * `remote.` services for the whole-client tier, without the generated + * Remote clients. Cordis resolves `ctx.remote.` to whichever service is + * registered under `remote.` (vendor cordis `utils.ts`, traceable get), so + * the carrier provides one Proxy per namespace: `remote..(...args)` + * calls the endpoint `/` over the roster's own Connection with the + * positional `args`, as a stream when the mock registered a stream script for + * it and as a unary call otherwise. A unary answer is returned unchanged and a + * unary rejection is folded the way the generated client folds a carrier + * throw (`gateway/internal`, or `gateway/cancelled` once the caller's signal + * aborted), so product code that never awaits a rejection sees none; stream + * items and failures pass through as the stream yields them. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-proxies + */ +import type { Context } from '@deepseek-ai/cordis' +import { cancelledFailure, carrierFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { ClientPluginModule } from './roster.ts' + +/** The assembly row the proxies stand in for; its generated clients exist only in built `lib/`. */ +export const REMOTES_PACKAGE = '@deepseek-ai/dsh-api-remotes' + +const PREFIX = 'remote.' + +/** + * Namespaces to provide: every `remote.` a roster module injects, plus the + * namespace of every endpoint the mock has a rule for. + * @param modules - loaded roster modules. + * @param mock - the spec's mock. + * @returns sorted namespace names. + */ +export function remoteNamespacesOf(modules: Iterable, mock: RemoteMock): readonly string[] { + const names = new Set() + for (const module of modules) { + for (const service of injectNames(module.inject)) if (service.startsWith(PREFIX)) names.add(service.slice(PREFIX.length)) + } + for (const endpoint of mock.endpoints()) { + const slash = endpoint.indexOf('/') + if (slash > 0 && !endpoint.startsWith('$')) names.add(endpoint.slice(0, slash)) + } + return [...names].sort() +} + +function injectNames(inject: ClientPluginModule['inject']): readonly string[] { + if (inject === undefined) return [] + if (Array.isArray(inject)) return inject as readonly string[] + return Object.keys(inject) +} + +/** + * Plugin providing the namespace proxies; `TestClient.start` mounts it before the Loader rows. + * @param namespaces - namespaces to provide. + * @param mock - the spec's mock, asked for each endpoint's mode. + * @returns the plugin. + */ +export function remoteProxiesPlugin(namespaces: readonly string[], mock: RemoteMock): ClientPluginModule { + return { + inject: ['connection'], + apply(ctx: Context) { + const connection = ctx.get('connection') as ConnectionHandle + for (const namespace of namespaces) ctx.provide(`${PREFIX}${namespace}`, namespaceProxy(namespace, connection, mock)) + }, + } +} + +function namespaceProxy(namespace: string, connection: ConnectionHandle, mock: RemoteMock): object { + return new Proxy(Object.create(null) as Record, { + get: (_target, property) => { + // No `then`: awaiting the namespace object itself must not call a method. + if (typeof property !== 'string' || property === 'then') return undefined + return (...values: readonly unknown[]): unknown => { + const endpoint = `${namespace}/${property}` + const args = [...values] + const signal = args.at(-1) instanceof AbortSignal ? (args.pop() as AbortSignal) : undefined + if (mock.modeOf(endpoint) === 'stream') { + const open = connection.rpc.open + /* v8 ignore next -- the mock transport always supplies openStream, so the Connection carrier exposes open. */ + if (open === undefined) throw new Error(`client-test-runtime: ${endpoint} is a stream but the carrier has no in-process opener`) + return open('/api', endpoint, { args }, signal ?? new AbortController().signal) + } + return connection.rpc.call('/api', endpoint, { args }, signal).catch((error: unknown) => + (signal?.aborted === true ? cancelledFailure(endpoint, error) : carrierFailure(endpoint, error))) + } + }, + }) +} diff --git a/packages/test-support/client-runtime/src/assembly/roster.ts b/packages/test-support/client-runtime/src/assembly/roster.ts new file mode 100644 index 0000000000..82aa14df93 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/roster.ts @@ -0,0 +1,163 @@ +/** + * Client roster: the ordered package-name rows a whole-client test boots, and + * the plan that annotates one with the rows the test provides itself. `webApp` + * and `bundleRoster` (`./bundle-roster.ts`) read rosters from the bundle patch + * files; a spec may also build one inline with {@link ClientRoster.of}. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/roster + */ +import type { Context } from '@deepseek-ai/cordis' +import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import { PLATFORM_MODULES } from '@deepseek-ai/dsh-client-web/src/platform.ts' + +/** One browser plugin row as `dsh.client` declares it, keyed by package name. */ +export interface ClientRosterRow { + /** Package name (== manifest entry id == Loader entry name). */ + readonly name: string + /** Package-name dependency edges from `dsh.client.inject` ([] when absent). */ + readonly inject: readonly string[] + /** Stage-one prefetch mark from `dsh.client.immediately` (false when absent). */ + readonly immediately: boolean +} + +/** Revision stamped on every synthesized row and batch; nothing is fetched by it. */ +const LOCAL_REV = 'local' + +/** + * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch + * holding every row, `rev: 'local'`, placeholder `/plugins//client.js` + * URLs, since every module is seeded in process and never fetched. Validation + * stays with the production `parseBootManifest` inside the module system: + * duplicate names and an empty roster are rejected there, not here. + * @param rows - roster rows in composition order. + * @returns the unparsed graph, as `createClientModuleSystem` consumes it. + */ +export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph { + const entries: WebBootEntry[] = rows.map(row => ({ + id: row.name, + url: `/plugins/${row.name}/client.js`, + rev: LOCAL_REV, + ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}), + ...(row.immediately ? { immediately: true } : {}), + })) + return { + rev: LOCAL_REV, + entries, + batches: [{ + phase: 'application', + url: '/plugins/local.js', + rev: LOCAL_REV, + entries: entries.map(entry => entry.id), + }], + } +} + +/** Module names the shell seeds before any row loads; an inject edge to one of them is satisfied without a row. */ +const PLATFORM_SEED: ReadonlySet = new Set(PLATFORM_MODULES) + +/** Immutable, name-addressable roster. */ +export class ClientRoster { + /** + * Build a roster from rows; duplicate names throw. + * @param rows - roster rows in composition order. + * @returns roster. + */ + static of(rows: readonly ClientRosterRow[]): ClientRoster { + const seen = new Set() + const duplicates = new Set() + for (const { name } of rows) { + if (seen.has(name)) duplicates.add(name) + seen.add(name) + } + if (duplicates.size > 0) { + throw new Error(`client-test-runtime: duplicate roster rows: ${[...duplicates].join(', ')}`) + } + return new ClientRoster(Object.freeze([...rows])) + } + + private constructor(readonly rows: readonly ClientRosterRow[]) {} + + /** + * Keep only `names`, preserving roster order; an unknown name throws with the roster listed. + * @param names - package names to keep. + * @returns sub-roster. + */ + pick(names: readonly string[]): ClientRoster { + const keep = this.known(names, 'pick') + return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name)))) + } + + /** + * The named rows plus every row they inject, transitively, in roster order: the rows a spec needs to boot the + * named plugins as the bundle composes them. The shell's platform modules (`PLATFORM_MODULES`, seeded statically + * rather than loaded as rows) end the walk. An unknown name throws with the roster listed; a row injecting any + * other package outside the roster throws, since the bundle itself would not boot. + * @param names - package names whose dependency cone to keep. + * @returns sub-roster. + */ + closure(names: readonly string[]): ClientRoster { + this.known(names, 'closure') + const byName = new Map(this.rows.map(row => [row.name, row])) + const keep = new Set() + const visit = (name: string, from: string | undefined): void => { + if (keep.has(name) || PLATFORM_SEED.has(name)) return + const row = byName.get(name) + if (row === undefined) { + throw new Error(`client-test-runtime: ${String(from)} injects ${name}, which is outside the roster`) + } + keep.add(name) + for (const dependency of row.inject) visit(dependency, name) + } + for (const name of names) visit(name, undefined) + return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name)))) + } + + /** + * Drop `names`; an unknown name throws with the roster listed. + * @param names - package names to drop. + * @returns sub-roster. + */ + without(names: readonly string[]): ClientRoster { + const drop = this.known(names, 'without') + return new ClientRoster(Object.freeze(this.rows.filter(row => !drop.has(row.name)))) + } + + private known(names: readonly string[], operation: string): ReadonlySet { + const rostered = this.rows.map(row => row.name) + const unknown = names.filter(name => !rostered.includes(name)) + if (unknown.length > 0) { + throw new Error( + `client-test-runtime: ${operation}() names outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`, + ) + } + return new Set(names) + } +} + +/** The module face the Loader materializes for one client plugin row. */ +export interface ClientPluginModule { + apply(ctx: Context, config?: unknown): unknown + readonly inject?: readonly string[] | Readonly> + readonly Config?: unknown +} + +/** What to boot and what the test supplies itself. */ +export interface AssemblyPlan { + readonly roster: ClientRoster + /** Row replacements by package name (the test's own implementation of that row). Names outside the roster throw. */ + readonly provide?: Readonly> +} + +/** + * Validate a plan against its roster. + * @param plan - plan to check. + * @throws {Error} naming any `provide` key outside the roster. + */ +export function assertPlan(plan: AssemblyPlan): void { + const rostered = plan.roster.rows.map(row => row.name) + const unknown = Object.keys(plan.provide ?? {}).filter(name => !rostered.includes(name)) + if (unknown.length > 0) { + throw new Error( + `client-test-runtime: provide names rows outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`, + ) + } +} diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts new file mode 100644 index 0000000000..7e26d9bbc8 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -0,0 +1,361 @@ +/** + * Whole-client test carrier: boots an {@link AssemblyPlan} through the + * production `bootClient` over an in-process module table, with a + * `RemoteMock` installed as the Connection carrier through `__DSH_TRANSPORT__.rpc`. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/test-client + */ +import { Context, type Plugin } from '@deepseek-ai/cordis' +import type { Entry } from '@deepseek-ai/cordis-plugin-loader' +import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client' +import type { ClientTransportHooks, ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts' +import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { act } from '@testing-library/react' +import { createInProcessModules, loadPluginModules } from './modules.ts' +import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts' +import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts' + +/** Carrier options. */ +export interface TestClientOptions { + /** + * Mount `uiRenderer` into an element (a fresh `document.body` child when `true`); requires jsdom and a roster + * that provides `uiRenderer`. Default false. + */ + readonly mount?: boolean | HTMLElement + /** Wait for `ctx.connection.state === 'connected'` before returning. Default true. */ + readonly awaitConnected?: boolean + /** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */ + readonly connectTimeoutMs?: number +} + +/** Page global the connection plugin reads its carrier from. */ +interface TransportGlobal { + __DSH_TRANSPORT__?: ClientTransportHooks +} + +const transportGlobal = globalThis as TransportGlobal + +/** + * Process globals every live client in this worker shares: the transport the + * `connection` plugin reads at apply, and the jsdom shims. The first holder + * installs them and remembers what was there; the last release removes the + * shims and restores the transport. A holder installs its transport for its + * own boot and for each rebuild it runs, both under the worker's boot turn, so + * overlapping clients and out-of-order disposal neither clobber a booting or + * rebuilding client nor leak into later tests. + */ +class SharedGlobals { + private holders = 0 + private previousTransport: ClientTransportHooks | undefined + private removeShims: (() => void) | undefined + + /** + * Point the transport global at `transport` for the boot or rebuild about to run; the holder must already hold. + * @param transport - carrier the next `connection` apply reads. + */ + install(transport: ClientTransportHooks): void { + transportGlobal.__DSH_TRANSPORT__ = transport + } + + /** + * Hold the globals with `transport` installed. + * @param transport - carrier the next boot reads. + * @returns the release for this holder. + */ + acquire(transport: ClientTransportHooks): () => void { + if (this.holders === 0) { + this.previousTransport = transportGlobal.__DSH_TRANSPORT__ + this.removeShims = installJsdomShims() + } + this.holders += 1 + this.install(transport) + return () => { + this.holders -= 1 + if (this.holders > 0) return + this.removeShims?.() + this.removeShims = undefined + if (this.previousTransport === undefined) delete transportGlobal.__DSH_TRANSPORT__ + else transportGlobal.__DSH_TRANSPORT__ = this.previousTransport + this.previousTransport = undefined + } + } +} + +const sharedGlobals = new SharedGlobals() + +/** + * Boots and entry rebuilds run one at a time per worker: the transport global + * must stay the acting client's until its `connection` row applies. + */ +let bootTurn: Promise = Promise.resolve() + +/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */ +function takeTurn(work: () => Promise): Promise { + const turn = bootTurn.then(work) + bootTurn = turn.catch(() => undefined) + return turn +} + +/** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */ +const DEFAULT_CONNECT_TIMEOUT_MS = 5_000 + +/** + * Browser globals jsdom lacks that roster plugins touch at apply or mount: + * client-hmr opens an `EventSource`, layout components observe element size. + * Inert stand-ins, installed only where the global is absent. + */ +const JSDOM_SHIMS: Readonly> = { + EventSource: class { + addEventListener(): void {} + close(): void {} + }, + ResizeObserver: class { + observe(): void {} + disconnect(): void {} + }, +} + +/** Install a shim for each absent global; the disposer deletes exactly those. */ +function installJsdomShims(): () => void { + const globals = globalThis as Record + const installed = Object.keys(JSDOM_SHIMS).filter(name => globals[name] === undefined) + for (const name of installed) globals[name] = JSDOM_SHIMS[name] + return () => { for (const name of installed) Reflect.deleteProperty(globals, name) } +} + +/** The Error a thrown value stands for: itself, or a new Error carrying its string form. */ +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function messageOf(error: unknown): string { + return toError(error).message +} + +function connectionOf(ctx: Context): ConnectionHandle { + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) { + throw new Error('client-test-runtime: the roster provides no `connection` service') + } + return connection +} + +/** Mount point plus whether `start` created it (and so removes it on dispose). */ +interface MountPoint { + readonly element: HTMLElement | undefined + readonly owned: boolean +} + +function resolveMountPoint(mount: boolean | HTMLElement | undefined): MountPoint { + if (mount === undefined || mount === false) return { element: undefined, owned: false } + if (typeof document === 'undefined') { + throw new Error('client-test-runtime: mount requires a DOM; add `// @vitest-environment jsdom` to the spec') + } + if (mount === true) { + const element = document.createElement('div') + document.body.appendChild(element) + return { element, owned: true } + } + return { element: mount, owned: false } +} + +/** Run `fn` inside React `act` when a DOM exists; plain await otherwise. */ +async function settle(fn: () => Promise): Promise { + if (typeof document === 'undefined') { + await fn() + return + } + await act(async () => { await fn() }) +} + +async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise { + const { state } = connectionOf(ctx) + if (state.getSnapshot() === 'connected') return + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe() + const unmatched = mock.log.unmatched().map(row => `${row.mode} ${row.endpoint}`) + const streams = mock.log.streams().map(row => `${row.endpoint} (${row.state})`) + reject(new Error( + `client-test-runtime: connection state is ${String(state.getSnapshot())} after ${timeoutMs}ms; ` + + `unmatched: [${unmatched.join(', ')}]; streams: [${streams.join(', ')}]`, + )) + }, timeoutMs) + const unsubscribe = state.subscribe(() => { + if (state.getSnapshot() !== 'connected') return + clearTimeout(timer) + unsubscribe() + resolve() + }) + }) +} + +/** A booted client under test. */ +export class TestClient { + /** + * Load the roster's modules, then, holding this worker's boot turn, install + * the mock as the Connection carrier and the jsdom shims and boot through + * `bootClient` over the synthesized boot graph; afterwards optionally mount + * and wait for the connection. The `@deepseek-ai/dsh-api-remotes` row is + * dropped from the roster: its generated Remote clients exist only in built + * `lib/`, and the `remote.` services the roster injects (plus the + * namespaces the mock has rules for at this point) are provided as + * contract-free proxies over the same Connection instead; a `provide` entry + * for that row is refused. On any failure the context is disposed, an owned + * mount removed, and this client's hold on the globals released before the + * original error is rethrown. + * @param plan - roster and annotations. + * @param mock - Remote mock answering every Gateway call. + * @param options - mount and readiness options. + * @returns the booted client. + */ + static async start( + plan: AssemblyPlan, + mock: RemoteMock, + options: TestClientOptions = {}, + ): Promise { + assertPlan(plan) + if (plan.provide?.[REMOTES_PACKAGE] !== undefined) { + throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote. services are the tier's proxies`) + } + const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE) + ? plan.roster.without([REMOTES_PACKAGE]) + : plan.roster + const ctx = new Context() + const transport: ClientTransportHooks = { rpc: mock.rpc } + let mountPoint: MountPoint = { element: undefined, owned: false } + let release: (() => void) | undefined + const restore = (): void => { + if (mountPoint.owned) mountPoint.element?.remove() + release?.() + } + try { + const modules = await loadPluginModules({ ...plan, roster }) + mountPoint = resolveMountPoint(options.mount) + await takeTurn(async () => { + release = sharedGlobals.acquire(transport) + const system = createInProcessModules(graphFromRoster(roster.rows), modules) + ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin) + await bootClient({ ctx, modules: system, manifest: system.manifest }) + }) + if (mountPoint.element !== undefined) { + if (ctx.get('uiRenderer') === undefined) { + throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`') + } + await mountClient(ctx, mountPoint.element) + } + if (options.awaitConnected !== false) { + await awaitConnected(ctx, mock, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) + } + } catch (error) { + try { + await settle(() => ctx.fiber.dispose()) + } catch { + // The boot failure is the error to report; a half-built tree failing to dispose adds nothing to it. + } finally { + restore() + } + throw error + } + return new TestClient(ctx, mock, mountPoint.element, restore) + } + + private disposing: Promise | undefined + + private constructor( + readonly ctx: Context, + readonly mock: RemoteMock, + readonly container: HTMLElement | undefined, + private readonly restore: () => void, + ) {} + + /** The carrier this client's `connection` row reads when it applies. */ + private get transport(): ClientTransportHooks { + return { rpc: this.mock.rpc } + } + + /** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */ + get connection(): ConnectionHandle { + return connectionOf(this.ctx) + } + + /** Flush pending React work and microtasks inside `act` (plain microtask flush without a DOM). */ + async flush(): Promise { + await settle(() => Promise.resolve()) + } + + /** + * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then `entry.refresh()`. The rebuild + * takes the worker's boot turn with this client's carrier installed, so a rebuilt `connection` row reads its + * own mock even while another client is live. Requires a live client: after `dispose()` the Loader holds no + * entries and the lookup throws before anything is installed. + * @param name - package name of the row. + */ + async reload(name: string): Promise { + const entry = this.entryOf(name) + await takeTurn(async () => { + sharedGlobals.install(this.transport) + await tearDownEntryFiber(entry) + await entry.refresh() + await this.ctx.loader.await() + }) + } + + /** + * Remove one Loader entry. + * @param name - package name of the row. + */ + async unload(name: string): Promise { + await this.ctx.loader.remove(this.entryOf(name).id) + } + + /** + * Dispose the plugin tree, then drop an owned mount and release this + * client's hold on the shared globals even when the tree fails to dispose, + * then `mock.assertNoUnmatched()` last so its failure is the test's reason + * without skipping the cleanup; when both the tree and the check fail, one + * error carries both messages. The first call owns the teardown and reports + * its failure; every later call waits for that teardown and resolves. + */ + async dispose(): Promise { + if (this.disposing !== undefined) { + await this.disposing.catch(() => undefined) + return + } + this.disposing = this.teardown() + await this.disposing + } + + private async teardown(): Promise { + let failure: Error | undefined + try { + await settle(() => this.ctx.fiber.dispose()) + } catch (error) { + failure = toError(error) + } finally { + this.restore() + } + if (failure === undefined) { + this.mock.assertNoUnmatched() + return + } + try { + this.mock.assertNoUnmatched() + } catch (unmatched) { + throw new Error(`${messageOf(unmatched)}\nclient-test-runtime: the plugin tree also failed to dispose: ${failure.message}`, { cause: failure }) + } + throw failure + } + + private entryOf(name: string): Entry { + const entries = [...this.ctx.loader.entries()] + const entry = entries.find(candidate => candidate.options.name === name) + if (entry === undefined) { + throw new Error( + `client-test-runtime: no Loader entry named ${name}; entries: ${entries.map(row => row.options.name).join(', ')}`, + ) + } + return entry + } +} diff --git a/packages/test-support/client-runtime/src/assembly/vitest.ts b/packages/test-support/client-runtime/src/assembly/vitest.ts new file mode 100644 index 0000000000..889ee2adcb --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/vitest.ts @@ -0,0 +1,60 @@ +/** Test-scoped Remote mock and lazy client boot, owned by native Vitest fixtures. */ +import { test, type TestAPI } from 'vitest' +import { RemoteMock, type MockedRemote } from '@deepseek-ai/dsh-remote-mock' +import type { AssemblyPlan } from './roster.ts' +import { remoteDefaultResponses } from './remote-default-responses.ts' +import { TestClient, type TestClientOptions } from './test-client.ts' + +/** Per-test controls; configure the mock before awaiting `start()`. */ +export interface ClientTestFixtures { + /** Fresh mock with the assembly's default responses already loaded. */ + mock: RemoteMock + /** Namespace proxy backed by the same native mocks used by the Connection carrier. */ + remote: MockedRemote + /** Await the one client owned by this test; rejects after the fixture closes. */ + start: () => Promise +} + +/** + * Extend Vitest with a fresh mock and a lazy, automatically disposed client. Repeated `start()` calls share one + * promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection. + * Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned. + * @param plan - roster and replacement modules, shared as configuration rather than as a running client. + * @param options - mount and readiness settings passed to `TestClient.start`. + * @returns Vitest's test function with test-scoped `mock` and `start` fixtures. + */ +export function createClientTest(plan: AssemblyPlan, options: TestClientOptions = {}): TestAPI { + return test.extend({ + remote: async ({ mock }, use) => { + await use(mock.remote) + }, + mock: async ({}, use) => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + try { + await use(mock) + } finally { + mock.assertNoUnmatched() + } + }, + start: async ({ mock }, use) => { + let pending: Promise | undefined + let closed = false + const start = (): Promise => { + if (closed) return Promise.reject(new Error('client-test-runtime: start() called after its test fixture closed')) + if (pending === undefined) { + pending = TestClient.start(plan, mock, options) + // Startup belongs to the caller; keep a rejection handler attached until teardown can await it. + void pending.catch(() => undefined) + } + return pending + } + try { + await use(start) + } finally { + closed = true + const client = await pending?.catch(() => undefined) + await client?.dispose() + } + }, + }) +} diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 23bc01bdfb..8472514901 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -45,8 +45,6 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' -export { scriptedSettingsRemote } from './settings-remote.ts' -export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts' export { TestWorkspaces } from './workspaces.ts' export { RemoteError, TestRemote } from './remote.ts' export { diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts deleted file mode 100644 index 97c2748480..0000000000 --- a/packages/test-support/client-runtime/src/settings-remote.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** Test double for the `settings` Remote namespace a bench's plugins inject. */ -import { vi } from 'vitest' - -/** The minimum a scripted namespace view carries for the double's own bookkeeping. */ -export interface ScriptedNamespace { - /** Namespace key the write addresses. */ - ns: string -} - -/** One scripted `settings` namespace face plus the controls a bench drives it with. */ -export interface ScriptedSettingsRemote { - /** - * The namespace face handed to `TestRemote` as `settings`. A plugin injecting - * `remote.settings` unparks on it, which is what most benches need; the - * describe answer is the same one the shared mirror would read. - */ - settings: { - describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }> - update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - } - /** Spy behind `settings.update`, for argument assertions. */ - update: ReturnType - /** Spy behind `settings.replace`, for argument assertions. */ - replace: ReturnType - /** Spy behind `settings.mutate`, for argument assertions. */ - mutate: ReturnType - /** - * Replace what the next describe answers with, as a Host commit would. - * @param namespaces - the namespace views to serve from now on. - */ - publish(namespaces: readonly View[]): void -} - -/** - * Build a scripted `settings` Remote namespace for a bench. Each write answers - * with the addressed namespace unchanged, so a bench that only needs its - * plugins to activate scripts nothing; one asserting a write reads the - * corresponding spy or replaces the face. - * @param namespaces - namespace views the first describe answers with. - * @param options - deployment facts the describe answer reports. - * @returns the face and its controls. - */ -export function scriptedSettingsRemote( - namespaces: readonly View[] = [], - options: { writable?: boolean; hasDocument?: boolean } = {}, -): ScriptedSettingsRemote { - let served = namespaces - const writable = options.writable ?? true - const hasDocument = options.hasDocument ?? false - const answer = (ns: string) => { - const view = served.find(candidate => candidate.ns === ns) - return Promise.resolve(view === undefined - ? { - ok: false as const, - error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, - } - : { ok: true as const, value: view }) - } - const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns)) - const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns)) - const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns)) - return { - settings: { - describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }), - update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision), - replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision), - mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision), - }, - update, - replace, - mutate, - publish(next) { served = next }, - } -} diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts new file mode 100644 index 0000000000..e73ce00818 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -0,0 +1,195 @@ +/** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts' +import { afterAll, describe, expect, it } from 'vitest' +import { MODULES_PACKAGE } from '../src/assembly/modules.ts' +import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts' + +describe('webApp (the real web profile)', () => { + it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => { + expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const names = webApp.rows.map(row => row.name) + expect(new Set(names).size).toBe(names.length) + const known = new Set([...names, ...Object.keys(getStaticModules())]) + const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`)) + expect(dangling).toEqual([]) + expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows) + }) + + it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => { + const immediate = new Set(webApp.rows.filter(row => row.immediately).map(row => row.name)) + expect(immediate.has(MODULES_PACKAGE)).toBe(true) + expect(immediate.has('@deepseek-ai/dsh-client-connection')).toBe(true) + expect(webApp.rows.find(row => row.name === '@deepseek-ai/dsh-api-gateway')?.inject) + .toEqual(['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection']) + const names = webApp.rows.map(row => row.name) + expect(names).toContain('@deepseek-ai/dsh-client-ui-settings-general') + expect(names).not.toContain('@deepseek-ai/dsh-llm') // Host only + expect(names).not.toContain('@deepseek-ai/dsh-client-ui-schedule') // inserted disabled + expect(names).not.toContain('@deepseek-ai/dsh-web-app') // Host runtime glue, its `/startup` row is a subpath + }) +}) + +/** A scratch installation: an anchor package, bundles under its node_modules, plugin packages beside them. */ +class Scratch { + readonly root = mkdtempSync(join(tmpdir(), 'bundle-roster-')) + readonly anchor = join(this.root, 'app', 'package.json') + + constructor() { + mkdirSync(join(this.root, 'app'), { recursive: true }) + writeFileSync(this.anchor, JSON.stringify({ name: 'app' })) + } + + pkg(name: string, manifest: Record, files: Record = {}): void { + const dir = join(this.root, 'app', 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...manifest })) + for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content) + } + + bundle(name: string, patch: string): void { + this.pkg(name, { dsh: { bundle: { patch: './cordis.patch.yml' } } }, { 'cordis.patch.yml': patch }) + } + + web(name: string, client: Record = {}): void { + this.pkg(name, { dsh: { client: { platform: 'web', ...client } } }) + } + + roster(bundles: readonly string[]): readonly string[] { + return bundleRoster(bundles, this.anchor).rows.map(row => row.name) + } +} + +describe('bundleRoster on a scratch installation', () => { + const scratch = new Scratch() + afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) }) + + it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => { + scratch.web('@t/a', { inject: ['@t/b'], immediately: true }) + scratch.web('@t/b') + scratch.web('@t/c') + scratch.web('plain') + scratch.pkg('@t/host', { dsh: {} }) + scratch.pkg('@t/node', { dsh: { client: { platform: 'node' } } }) + scratch.bundle('@t/base', ` +- insert: + - id: a + name: '@t/a' + config: + root: !!js process.cwd() + - id: host + name: '@t/host' + disabled: !!js process.platform === 'win32' + - id: node + name: '@t/node' + - id: c + name: '@t/c' + - id: sub + name: '@t/a/extra' + - id: builtin + name: 'cordis:group' +`) + scratch.bundle('@t/web', ` +- id: c + disabled: true +- insert: + - id: b + name: '@t/b' + - id: b-again + name: '@t/b' + - id: plain + name: plain + - id: off + name: '@t/off' + disabled: true +`) + const roster = bundleRoster(['@t/base', '@t/web'], scratch.anchor) + expect(roster.rows).toEqual([ + { name: '@t/a', inject: ['@t/b'], immediately: true }, + { name: '@t/b', inject: [], immediately: false }, + { name: 'plain', inject: [], immediately: false }, + ]) + expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c']) + }) + + it('descends into Loader groups and lets a disabled group disable every row beneath it', () => { + scratch.web('@t/grouped') + scratch.web('@t/grouped-off') + scratch.web('@t/nested') + scratch.bundle('@t/groups', ` +- insert: + - id: on + name: cordis:group + group: true + config: + - id: grouped + name: '@t/grouped' + - id: inner + name: cordis:group + group: true + config: + - id: nested + name: '@t/nested' + - id: off + name: cordis:group + group: true + disabled: true + config: + - id: grouped-off + name: '@t/grouped-off' +`) + expect(scratch.roster(['@t/groups'])).toEqual(['@t/grouped', '@t/nested']) + }) + + it('refuses a browser row whose disabled value is a !!js expression', () => { + scratch.web('@t/maybe') + scratch.bundle('@t/maybe-bundle', ` +- insert: + - id: maybe + name: '@t/maybe' + disabled: !!js process.platform === 'win32' +`) + expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate') + }) + + it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => { + expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from') + scratch.pkg('@t/no-patch', { dsh: {} }) + expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in') + scratch.bundle('@t/not-a-list', 'insert: []\n') + expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches') + }) + + it('fails loud on a patch that does not apply as written', () => { + scratch.bundle('@t/dangling', ` +- id: nowhere + disabled: true +`) + expect(() => scratch.roster(['@t/dangling'])).toThrow('bundle patch patch: entry "nowhere" not found') + }) + + it('fails loud on an enabled row whose package does not resolve or names another package', () => { + scratch.bundle('@t/unresolved', ` +- insert: + - id: ghost + name: '@t/ghost' +`) + expect(() => scratch.roster(['@t/unresolved'])).toThrow('cannot resolve plugin package @t/ghost from @t/unresolved') + scratch.bundle('@t/builtin', ` +- insert: + - id: fs + name: fs +`) + // A Node builtin name has no resolution paths at all. + expect(() => scratch.roster(['@t/builtin'])).toThrow('cannot resolve plugin package fs from @t/builtin') + scratch.pkg('@t/alias', { name: '@t/real' }) + scratch.bundle('@t/misnamed', ` +- insert: + - id: alias + name: '@t/alias' +`) + expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts new file mode 100644 index 0000000000..4bd4835b59 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts @@ -0,0 +1,37 @@ +/** Real bundle dependency closures activate their requested client plugins without extra roster rows. */ +import { FiberState } from '@deepseek-ai/cordis' +import { describe, expect, vi } from 'vitest' +import { createClientTest, webApp } from '../src/assembly/index.ts' + +const HMR = '@deepseek-ai/dsh-client-hmr' +const MODULES = '@deepseek-ai/dsh-client-modules' +const SESSIONS = '@deepseek-ai/dsh-api-session-controller' +const FILE_UPLOAD = '@deepseek-ai/dsh-client-file-upload' +const hmrRoster = webApp.closure([HMR]) +const sessionRoster = webApp.closure([SESSIONS]) +const hmrTest = createClientTest({ roster: hmrRoster }, { awaitConnected: false }) +const sessionTest = createClientTest({ roster: sessionRoster }) + +describe('bundle dependency closures', () => { + hmrTest('activates HMR with its declared module provider and no Connection', async ({ start }) => { + expect(hmrRoster.rows.map(row => row.name)).toContain(MODULES) + const client = await start() + const entry = [...client.ctx.loader.entries()].find(row => row.options.name === HMR) + expect(entry?.fiber?.state).toBe(FiberState.ACTIVE) + expect(client.ctx.get('modules')).toBeDefined() + expect(client.ctx.get('connection')).toBeUndefined() + }, 60_000) + + sessionTest('activates Session Controller with its declared file-upload provider', async ({ mock, start }) => { + expect(sessionRoster.rows.map(row => row.name)).toContain(FILE_UPLOAD) + const client = await start() + const entry = [...client.ctx.loader.entries()].find(row => row.options.name === SESSIONS) + expect(entry?.fiber?.state).toBe(FiberState.ACTIVE) + expect(client.ctx.get('fileUpload')).toBeDefined() + await vi.waitFor(() => { + expect(client.ctx.sessions.list.getSnapshot().phase).toBe('ready') + expect(mock.log.calls('session/list')).toHaveLength(1) + expect(mock.log.streams('session/control')).toHaveLength(1) + }) + }, 60_000) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts new file mode 100644 index 0000000000..999a99ea70 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts @@ -0,0 +1,105 @@ +/** + * In-process module arrival: dynamic `/client` imports resolve through the + * repository path aliases, provided rows replace them, the bootstrap row is the + * static namespace, and the resulting module system serves the vendored Loader + * without ever loading a bundle. + */ +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import { parseBootManifest } from '@deepseek-ai/dsh-client-modules/client' +import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import * as uiRenderer from '@deepseek-ai/dsh-client-ui-renderer/client' +import * as typertRegistry from '@deepseek-ai/dsh-typert-registry/client' +import { describe, expect, it, onTestFinished } from 'vitest' +import { ClientRoster } from '../src/assembly/index.ts' +import { MODULES_PACKAGE, createInProcessModules, loadPluginModules } from '../src/assembly/modules.ts' + +const RENDERER = '@deepseek-ai/dsh-client-ui-renderer' +const TYPERT = '@deepseek-ai/dsh-typert-registry' +const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' +const MISSING = '@deepseek-ai/dsh-client-does-not-exist' + +const row = (name: string, immediately = false) => ({ name, inject: [], immediately }) + +function graph(names: readonly string[]): WebBootGraph { + return { + rev: 'local', + entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })), + batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }], + } +} + +describe('loadPluginModules', () => { + it('imports real rows dynamically, takes provided rows from the plan, and pins the bootstrap row', async () => { + const provided = { apply: () => {} } + const roster = ClientRoster.of([row(TYPERT), row(MODULES_PACKAGE), row(BRAND), row(RENDERER)]) + const modules = await loadPluginModules({ roster, provide: { [BRAND]: provided } }) + expect([...modules.keys()]).toEqual([TYPERT, MODULES_PACKAGE, BRAND, RENDERER]) + expect(modules.get(TYPERT)).toBe(typertRegistry) + expect(modules.get(RENDERER)).toBe(uiRenderer) + expect(modules.get(MODULES_PACKAGE)).toBe(modulesClient) + expect(modules.get(BRAND)).toBe(provided) + }) + + it('prefixes the package name when a /client import fails', async () => { + const roster = ClientRoster.of([row(MISSING)]) + await expect(loadPluginModules({ roster })).rejects.toThrow( + new RegExp(`^client-test-runtime: cannot import ${MISSING}/client: .+`), + ) + }) + + it('refuses a provided bootstrap row', async () => { + const roster = ClientRoster.of([row(MODULES_PACKAGE)]) + await expect(loadPluginModules({ roster, provide: { [MODULES_PACKAGE]: { apply: () => {} } } })) + .rejects.toThrow(`${MODULES_PACKAGE} is the bootstrap module and cannot be provided`) + }) +}) + +describe('createInProcessModules', () => { + it('serves import() and prefetch() from registered factories and never loads a bundle', async () => { + const roster = ClientRoster.of([row(MODULES_PACKAGE, true), row(TYPERT, true), row(RENDERER)]) + const modules = await loadPluginModules({ roster }) + const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules) + + await expect(system.prefetch(TYPERT)).resolves.toBeUndefined() + await expect(system.prefetch(RENDERER)).resolves.toBeUndefined() + await expect(system.prefetch(MODULES_PACKAGE)).resolves.toBeUndefined() + expect(system.loadCache.has(TYPERT)).toBe(false) + expect(system.loadCache.has(RENDERER)).toBe(false) + await expect(system.import(RENDERER, '', {})).resolves.toBe(uiRenderer) + await expect(system.import(`${TYPERT}/client`, '', {})).resolves.toBe(typertRegistry) + await expect(system.import(MODULES_PACKAGE, '', {})).resolves.toBe(modulesClient) + expect(system.loadCache.get(TYPERT)?.exports).toBe(typertRegistry) + expect(system.loadCache.get(RENDERER)?.exports).toBe(uiRenderer) + await expect(system.import('@deepseek-ai/dsh-client-unknown', '', {})).rejects.toThrow('cannot resolve') + }) + + it('rejects loudly instead of fetching when a graph row has no loaded module', async () => { + const system = createInProcessModules(graph(['ghost']), new Map()) + await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + }) + + it('exposes the graph parsed by the production validator as its manifest', () => { + const raw = graph(['a', 'b']) + expect(createInProcessModules(raw, new Map()).manifest).toEqual(parseBootManifest(raw)) + expect(() => createInProcessModules({ ...raw, batches: [] }, new Map())).toThrow('belongs to no initial-load batch') + }) + + it('boots the vendored Loader over the in-process table with every entry active', async () => { + const roster = ClientRoster.of([row(TYPERT, true), row(RENDERER, true), row(MODULES_PACKAGE, true)]) + const modules = await loadPluginModules({ roster }) + const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Loader) + ctx.loader.internal = system as never + await Promise.all(roster.rows.map(r => ctx.loader.create({ name: r.name }))) + await ctx.loader.await() + expect(ctx.get('typert')).toBeDefined() + expect(ctx.get('slots')).toBeDefined() + expect(ctx.get('modules')).toBe(system) + expect([...ctx.loader.entries()].every(entry => entry.fiber !== undefined)).toBe(true) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts new file mode 100644 index 0000000000..502a0f8fe7 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts @@ -0,0 +1,22 @@ +/** assertPlan: provide keys must name roster rows. */ +import { describe, expect, it } from 'vitest' +import { assertPlan } from '../src/assembly/roster.ts' +import { ClientRoster } from '../src/assembly/roster.ts' + +const roster = ClientRoster.of([ + { name: 'a', inject: [], immediately: false }, + { name: 'b', inject: [], immediately: false }, +]) +const plugin = { apply: () => {} } + +describe('assertPlan', () => { + it('accepts a bare roster and provided roster rows', () => { + expect(() => { assertPlan({ roster }) }).not.toThrow() + expect(() => { assertPlan({ roster, provide: { a: plugin, b: plugin } }) }).not.toThrow() + }) + + it('rejects provide keys outside the roster and lists the roster', () => { + expect(() => { assertPlan({ roster, provide: { a: plugin, zz: plugin, yy: plugin } }) }) + .toThrow('provide names rows outside the roster: zz, yy; roster: a, b') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts new file mode 100644 index 0000000000..7ae3666000 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts @@ -0,0 +1,93 @@ +/** Remote proxies: namespace discovery from roster injects and mock rules, and per-call routing over the Connection to the mock. */ +import { RemoteMock, frames, ok, openStream } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished } from 'vitest' +import type { ClientPluginModule } from '../src/assembly/index.ts' +import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' +import { remoteNamespacesOf } from '../src/assembly/remote-proxies.ts' + +/** The api-remotes row and its cone (Gateway client, Typert registry, Connection); TestClient drops the api-remotes row itself. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-remotes']) + +type RemoteFace = Record unknown>> + +async function drain(source: AsyncIterable): Promise { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} + +describe('remoteNamespacesOf', () => { + it('collects remote. injects in both inject forms plus the namespaces of registered endpoints, skipping Gateway-internal ones', () => { + const modules: ClientPluginModule[] = [ + { apply() {}, inject: ['slots', 'remote.settings', 'remote'] }, + { apply() {}, inject: { 'remote.session': { required: true }, connection: { required: false } } }, + { apply() {} }, + ] + const mock = RemoteMock.create().unary('goals/create', ok({ id: 'g' })).stream('workspace/follow', openStream()) + expect(remoteNamespacesOf(modules, mock)).toEqual(['goals', 'session', 'settings', 'workspace']) + }) +}) + +describe('remote proxies over a booted client', () => { + async function booted(configure: (mock: RemoteMock) => void = () => {}) { + const mock = RemoteMock.create().load(remoteDefaultResponses) + configure(mock) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + return { client, mock, remote: (client.ctx as unknown as { remote: RemoteFace }).remote } + } + + it('drops the api-remotes row and answers ctx.remote.. with the mock value, logging positional args', async () => { + const { client, mock, remote } = await booted((m) => { + m.unary('session/rename', ok({ title: 'renamed', seq: 3 })) + m.unary('session/cancel', { ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } }) + }) + expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain('@deepseek-ai/dsh-api-remotes') + await expect(remote.session!.rename!({ sessionId: 's1', title: 'renamed' })).resolves.toEqual({ ok: true, value: { title: 'renamed', seq: 3 } }) + await expect(remote.session!.cancel!({ sessionId: 's1' }, new AbortController().signal)) + .resolves.toEqual({ ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } }) + expect(mock.log.calls('session/rename').map(call => call.args)).toEqual([[{ sessionId: 's1', title: 'renamed' }]]) + expect(mock.log.calls('session/cancel').map(call => call.args)).toEqual([[{ sessionId: 's1' }]]) + expect((remote.session as unknown as { then?: unknown }).then).toBeUndefined() + expect((remote.session as unknown as Record)[Symbol.toStringTag]).toBeUndefined() + }, 60_000) + + it('folds a call without a rule into gateway/internal and leaves the miss in the log for dispose() to report', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const remote = (client.ctx as unknown as { remote: RemoteFace }).remote + await expect(remote.session!.search!({ query: 'x' })).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/internal', message: expect.stringMatching(/^client api: session\/search failed: remote-mock: no rule for session\/search; registered: \$events, /) as string }, + }) + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/search', mode: 'unary' }]) + await expect(client.dispose()).rejects.toThrow('session/search (unary)') + }) + + it('folds a rule rejection like the generated client: gateway/internal, or gateway/cancelled once the signal aborted', async () => { + const { mock, remote } = await booted((m) => { + m.unary('session/rename', () => Promise.reject(new Error('wire down'))) + m.unary('session/cancel', () => new Promise(() => {})) + }) + await expect(remote.session!.rename!({ sessionId: 's1', title: 't' })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: wire down' }, + }) + const controller = new AbortController() + const cancelling = remote.session!.cancel!({ sessionId: 's1' }, controller.signal) as Promise + controller.abort() + await expect(cancelling).resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) + expect(mock.log.calls('session/rename').map(call => call.state)).toEqual(['failed']) + expect(mock.log.calls('session/cancel').map(call => call.state)).toEqual(['pending']) + }) + + it('opens registered streams and hands their items and failures through unchanged', async () => { + const { remote } = await booted((m) => { + m.stream('session/follow', frames([{ type: 'a' }, { type: 'b' }])) + m.stream('session/control', ((_args, stream) => { stream.fail(new Error('flap')) })) + }) + const signal = new AbortController().signal + await expect(drain(remote.session!.follow!({ address: { kind: 'session', sessionId: 's1' } }, signal) as AsyncIterable)) + .resolves.toEqual([{ type: 'a' }, { type: 'b' }]) + await expect(drain(remote.session!.control!({}) as AsyncIterable)).rejects.toThrow('flap') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts new file mode 100644 index 0000000000..d1ccea2644 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts @@ -0,0 +1,112 @@ +/** ClientRoster: construction, duplicate refusal, order-preserving pick/closure/without with loud unknown names; graphFromRoster. */ +import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client' +import { describe, expect, it } from 'vitest' +import { ClientRoster, graphFromRoster, type ClientRosterRow } from '../src/assembly/roster.ts' +import { MODULES_PACKAGE } from '../src/assembly/modules.ts' + +const ROWS: readonly ClientRosterRow[] = [ + { name: MODULES_PACKAGE, inject: [], immediately: true }, + { name: '@x/a', inject: [], immediately: false }, + { name: '@x/b', inject: ['@x/a'], immediately: true }, +] + +describe('graphFromRoster', () => { + it('synthesizes one application batch over every row with placeholder URLs', () => { + expect(graphFromRoster(ROWS)).toEqual({ + rev: 'local', + entries: [ + { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, + { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' }, + { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, + ], + batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], + }) + }) + + it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => { + const manifest = parseBootManifest(graphFromRoster(ROWS)) + expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately }))) + expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js']) + const row = ROWS[1]! + expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"') + expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array') + }) + + it('imports seeded rows without reaching the bundle transport', async () => { + const loaded: string[] = [] + const pendingQueue: ClientBundleRegistration[] = [] + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue, + load: (registration) => { pendingQueue.push(registration) }, + create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: {} }, options), + } + const a = { apply() {} } + const b = { apply() {} } + const modules = target.create({ + boot: graphFromRoster(ROWS), + staticModules: { '@x/a': a, '@x/b': b }, + loadBundle: async (url) => { loaded.push(url) }, + }) + await expect(modules.import('@x/a')).resolves.toBe(a) + await expect(modules.import('@x/b')).resolves.toBe(b) + expect(modules.manifest).toEqual(parseBootManifest(graphFromRoster(ROWS))) + expect(loaded).toEqual([]) + }) +}) + +const A = { name: 'a', inject: [], immediately: true } +const B = { name: 'b', inject: ['a'], immediately: false } +const C = { name: 'c', inject: ['b'], immediately: false } + +describe('ClientRoster', () => { + it('freezes a copy of the rows in composition order', () => { + const rows = [A, B, C] + const roster = ClientRoster.of(rows) + expect(roster.rows).toEqual([A, B, C]) + expect(roster.rows).not.toBe(rows) + expect(Object.isFrozen(roster.rows)).toBe(true) + rows.push(A) + expect(roster.rows).toHaveLength(3) + }) + + it('refuses duplicate names, listing each duplicate once', () => { + expect(() => ClientRoster.of([A, B, A, C, B, A])).toThrow('duplicate roster rows: a, b') + }) + + it('pick keeps roster order regardless of the requested order', () => { + const roster = ClientRoster.of([A, B, C]) + expect(roster.pick(['c', 'a']).rows.map(row => row.name)).toEqual(['a', 'c']) + expect(roster.pick([]).rows).toEqual([]) + expect(Object.isFrozen(roster.pick(['b']).rows)).toBe(true) + }) + + it('without drops the named rows', () => { + const roster = ClientRoster.of([A, B, C]) + expect(roster.without(['b']).rows.map(row => row.name)).toEqual(['a', 'c']) + expect(roster.without(['a', 'b', 'c']).rows).toEqual([]) + }) + + it('closure keeps the named rows and everything they inject, transitively, in roster order', () => { + const D = { name: 'd', inject: [], immediately: false } + const roster = ClientRoster.of([A, D, B, C]) + expect(roster.closure(['c']).rows.map(row => row.name)).toEqual(['a', 'b', 'c']) + expect(roster.closure(['b', 'd']).rows.map(row => row.name)).toEqual(['a', 'd', 'b']) + expect(roster.closure(['a']).rows.map(row => row.name)).toEqual(['a']) + expect(roster.closure([]).rows).toEqual([]) + expect(Object.isFrozen(roster.closure(['c']).rows)).toBe(true) + }) + + it('pick, closure, and without throw on unknown names with the roster listed; closure also refuses an inject outside the roster', () => { + const roster = ClientRoster.of([A, B]) + expect(() => roster.pick(['a', 'zz'])).toThrow('pick() names outside the roster: zz; roster: a, b') + expect(() => roster.closure(['zz'])).toThrow('closure() names outside the roster: zz; roster: a, b') + expect(() => roster.without(['x', 'y'])).toThrow('without() names outside the roster: x, y; roster: a, b') + expect(() => ClientRoster.of([B]).closure(['b'])).toThrow('b injects a, which is outside the roster') + }) + + it('closure treats the platform seed modules as satisfied without a row', () => { + const seeded = { name: 'seeded', inject: ['@deepseek-ai/dsh-client-ui-primitives', 'react', 'a'], immediately: false } + expect(ClientRoster.of([A, seeded]).closure(['seeded']).rows.map(row => row.name)).toEqual(['a', 'seeded']) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts new file mode 100644 index 0000000000..26afee3272 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -0,0 +1,110 @@ +/** TestClient without a DOM: the api roster boots and connects; mount is refused; flush degrades to a microtask flush. */ +import type { ClientTransportHooks } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' + +/** The Gateway client and what it injects: the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry']) +const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks } + +describe('TestClient (node environment)', () => { + it('boots the api roster, connects, and flushes without a DOM', async () => { + expect(typeof document).toBe('undefined') + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + await client.flush() + }, 60_000) + + it('releases the shared globals even when the plugin tree fails to dispose, and still rethrows', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber) + const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => { + await dispose() + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercises normalization of non-Error teardown failures. + return Promise.reject('teardown boom') + }) + onTestFinished(async () => { + failure.mockRestore() + try { await dispose() } finally { await client.dispose() } + }) + await expect(client.dispose()).rejects.toThrow('teardown boom') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + await client.dispose() // idempotent after a failed teardown + }) + + it('rethrows a row that fails to apply, releases the globals, and lets the next boot take its turn', async () => { + const failing = { apply(): void { throw new Error('apply boom') } } + await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create())) + .rejects.toThrow(/apply boom|typert-registry/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + }) + + it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => { + const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes']) + await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create())) + .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote. services are the tier\'s proxies') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber) + const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => { + await dispose() + throw new Error('teardown boom') + }) + onTestFinished(async () => { + failure.mockRestore() + try { await dispose() } finally { await client.dispose() } + }) + await mock.dispatch('nowhere/call', []).catch(() => undefined) + await expect(client.dispose()).rejects.toThrow(/nowhere\/call \(unary\)[\s\S]*also failed to dispose: teardown boom/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + }) + + it('lets a concurrent second dispose wait for the first teardown instead of returning early', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const first = client.dispose() + const second = client.dispose() + await second + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + await first + }) + + it('refuses to mount without a DOM before installing the transport', async () => { + await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true })) + .rejects.toThrow('mount requires a DOM') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('fails loud, then restores the transport, when the roster cannot provide a connection', async () => { + await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create())) + .rejects.toThrow('provides no `connection` service') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('skips readiness on request and restores a pre-existing transport on dispose', async () => { + const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) } + globals.__DSH_TRANSPORT__ = previous + onTestFinished(() => { delete globals.__DSH_TRANSPORT__ }) + const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false }) + expect(globals.__DSH_TRANSPORT__).not.toBe(previous) + expect(client.ctx.get('typert')).toBeDefined() + expect(() => client.connection).toThrow('provides no `connection` service') + await client.dispose() + expect(globals.__DSH_TRANSPORT__).toBe(previous) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts new file mode 100644 index 0000000000..788c640b7f --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +/** + * TestClient over the web profile's roster read from its bundles: production + * `bootClient` over in-process modules, every Remote call answered by a + * `RemoteMock` installed as the Connection carrier, mount, HMR-style reload, + * unload, and fail-loud teardown. + */ +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { AssemblyPlan, TestClientOptions } from '../src/assembly/index.ts' +import { ClientRoster, TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' + +/** The Gateway client and what it injects: the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +/** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */ +const SIDEBAR_SETTINGS = 'sidebar.settings' as never +const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' +const globals = globalThis as { __DSH_TRANSPORT__?: unknown; EventSource?: unknown; ResizeObserver?: unknown } +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 + +async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start(plan, mock, options) + onTestFinished(() => client.dispose()) + return client +} + +describe('TestClient (jsdom)', () => { + it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp }, mock, { mount: true }) + expect(client.connection.state.getSnapshot()).toBe('connected') + expect(mock.log.streams('$events')).toHaveLength(1) + const container = client.container! + expect(document.body.contains(container)).toBe(true) + expect(container.childElementCount).toBeGreaterThan(0) + expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect(globals.EventSource).toBeDefined() + expect(globals.ResizeObserver).toBeDefined() + await client.dispose() + expect(document.body.contains(container)).toBe(false) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + expect(globals.ResizeObserver).toBeUndefined() + await client.dispose() + }, COLD_BOOT_TIMEOUT_MS) + + it('leaves a pre-existing global alone and removes only the shims it installed', async () => { + const existing = { existing: true } + vi.stubGlobal('ResizeObserver', existing) + onTestFinished(() => { vi.unstubAllGlobals() }) + const client = await started({ roster: API_ROSTER }) + expect(globals.ResizeObserver).toBe(existing) + expect(globals.EventSource).toBeDefined() + await client.dispose() + expect(globals.ResizeObserver).toBe(existing) + expect(globals.EventSource).toBeUndefined() + }) + + it('boots overlapping clients one at a time onto their own mocks and keeps the shared globals until the last dispose', async () => { + const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 })) + const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 })) + const [a, b] = await Promise.all([ + TestClient.start({ roster: API_ROSTER }, mockA), + TestClient.start({ roster: API_ROSTER }, mockB), + ]) + onTestFinished(() => b.dispose()) + const rename = async (client: TestClient): Promise => + (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise } } }).remote.session.rename({ sessionId: 's', title: 't' }) + await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) + await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } }) + expect(mockA.log.calls('session/rename')).toHaveLength(1) + expect(mockB.log.calls('session/rename')).toHaveLength(1) + // A rebuilt connection row reads its own mock even after another client installed the transport last. + await a.reload('@deepseek-ai/dsh-client-connection') + await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') }) + await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) + expect(mockA.log.calls('session/rename')).toHaveLength(2) + expect(mockB.log.calls('session/rename')).toHaveLength(1) + await a.dispose() + expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect(globals.EventSource).toBeDefined() + await b.dispose() + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + }) + + it('boots the api subset without a mount and exposes the typed Remote', async () => { + const client = await started({ roster: API_ROSTER }) + expect(client.container).toBeUndefined() + expect(client.ctx.remote.session).toBeDefined() + expect(client.connection.state.getSnapshot()).toBe('connected') + }) + + it('refuses to mount a roster that provides no uiRenderer instead of returning an empty container', async () => { + const before = document.body.childElementCount + await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true })) + .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`') + expect(document.body.childElementCount).toBe(before) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('creates no mount element when the roster cannot be loaded', async () => { + const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }]) + const before = document.body.childElementCount + await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow() + expect(document.body.childElementCount).toBe(before) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('mounts into a caller-supplied element and leaves it in place on dispose', async () => { + const host = document.createElement('main') + document.body.appendChild(host) + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp }, mock, { mount: host }) + expect(client.container).toBe(host) + expect(host.childElementCount).toBeGreaterThan(0) + await client.dispose() + expect(document.body.contains(host)).toBe(true) + host.remove() + }) + + it('boots with a provided row in place of the real plugin', async () => { + const apply = vi.fn() + const client = await started({ roster: webApp, provide: { [BRAND]: { apply } } }, { mount: true }) + expect(apply).toHaveBeenCalledOnce() + expect([...client.ctx.loader.entries()].some(entry => entry.options.name === BRAND)).toBe(true) + }) + + it('reload rebuilds the declaring entry; unload collapses it', async () => { + const client = await started({ roster: webApp }, { mount: true }) + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) + await client.reload(SIDEBAR) + await client.flush() + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) + await client.unload(SIDEBAR) + await client.flush() + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0) + await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`) + }) + + it('fails loud by teardown at the latest when a boot-time endpoint has no fixture', async () => { + const mock = RemoteMock.create() + const run = TestClient.start({ roster: webApp }, mock, { mount: true }).then(client => client.dispose()) + await expect(run).rejects.toThrow(/session\/control|workspace\/follow|session\/list|settings\/describe/) + }) + + it('waits through a carrier flap for the connection to become ready', async () => { + let opens = 0 + const mock = RemoteMock.create().load(remoteDefaultResponses).stream('$events', (_args, stream) => { + opens += 1 + const first = opens === 1 + setTimeout(() => { + if (first) stream.fail(new Error('flap')) + // Branded on the Gateway side; the test mints a plain string. + else stream.push({ type: 'ready', clientId: 'reconnected' as never, host: { home: '/home/mock' } }) + }, 20) + }) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + expect(mock.log.streams('$events')).toHaveLength(2) + }) + + it('reports the log when the connection never becomes ready', async () => { + // No fixtures: workspace-controller's follow has no rule, so the proxy dispatches it as a unary call the mock + // logs as unmatched, while $events never sends ready. + const roster = webApp.closure(['@deepseek-ai/dsh-api-workspace-controller']) + const mock = RemoteMock.create().stream('$events', openStream([])) + await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 })) + .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts new file mode 100644 index 0000000000..8a31f305ad --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts @@ -0,0 +1,64 @@ +/** Native fixture cleanup remains active after assertion failure and without a started client. */ +import { afterAll, describe, expect } from 'vitest' +import { ok } from '@deepseek-ai/dsh-remote-mock' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createClientTest, webApp, type TestClient } from '../src/assembly/index.ts' + +const test = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) }) +const clients: TestClient[] = [] +const expired: (() => Promise)[] = [] + +afterAll(async () => { + expect(clients).toHaveLength(2) + for (const client of clients) { + expect(client.ctx.get('loader')).toBeUndefined() + expect(client.mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + } + for (const start of expired) { + await expect(start()).rejects.toThrow('after its test fixture closed') + } + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) +}) + +describe('createClientTest', () => { + test('allows response configuration before boot and shares concurrent starts', async ({ mock, remote, start }) => { + expired.push(start) + expect(remote).toBe(mock.remote) + remote.session.rename.mockResolvedValueOnce(ok({ title: 'test title', seq: 1 })) + const first = start() + const second = start() + expect(second).toBe(first) + const client = await first + clients.push(client) + expect(client.mock).toBe(mock) + expect(mock.log.streams('$events')).toHaveLength(1) + await expect(client.ctx.remote.session.rename({ sessionId: 's' as SessionId, title: 't' })) + .resolves.toEqual(ok({ title: 'test title', seq: 1 })) + expect(remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 's', title: 't' }) + }) + + test.fails('disposes its client even when the test assertion fails', async ({ start }) => { + expired.push(start) + clients.push(await start()) + expect.fail('fixture cleanup negative control') + }) + + test('does not boot an unused start fixture', ({ mock, start }) => { + expired.push(start) + expect(mock.log.streams()).toEqual([]) + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) + }) + + test('owns a fresh mock when only the mock fixture is requested', ({ mock }) => { + expect(mock.log.calls()).toEqual([]) + expect(mock.log.streams()).toEqual([]) + expect(mock.endpoints()).not.toContain('session/rename') + }) +}) + +const missingConnection = createClientTest({ roster: webApp.pick(['@deepseek-ai/dsh-typert-registry']) }) +missingConnection('keeps a rejected startup with its caller and releases its globals', async ({ start }) => { + expired.push(start) + await expect(start()).rejects.toThrow('provides no `connection` service') + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) +}) diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index bb297bb0b6..d7bbec5716 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -6,7 +6,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { TestRemote } from '../src/remote.ts' -import { scriptedSettingsRemote } from '../src/settings-remote.ts' describe('TestRemote', () => { it('delivers a forwarded event to its subscribers and stops after disposal', async () => { @@ -60,39 +59,3 @@ describe('TestRemote', () => { await ctx.fiber.dispose() }) }) - -describe('scriptedSettingsRemote', () => { - it('serves, writes, and replaces its scripted namespace list', async () => { - const first = { ns: 'first', revision: 1 } - const second = { ns: 'second', revision: 2 } - const remote = scriptedSettingsRemote([first]) - - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: true, hasDocument: false, namespaces: [first] }, - }) - await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first }) - await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({ - ok: false, - error: { code: 'settings/rejected', details: { ns: 'missing' } }, - }) - await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first }) - expect(remote.update).toHaveBeenCalledWith('first', {}, undefined) - expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined) - expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined) - - remote.publish([second]) - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: true, hasDocument: false, namespaces: [second] }, - }) - }) - - it('reports explicit deployment facts', async () => { - const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true }) - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: false, hasDocument: true, namespaces: [] }, - }) - }) -}) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index bc8fbf0688..d73de457a3 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": ["client-build-environment", "node"] }, "include": [ "src" @@ -11,6 +12,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../api/gateway/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, @@ -55,6 +65,21 @@ }, { "path": "../../subagent/subagent" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../client/modules" + }, + { + "path": "../../client/web" + }, + { + "path": "../remote-mock" + }, + { + "path": "../../client/hmr" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5bafb0d2a..ed003616c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3261,6 +3261,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3655,6 +3658,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-util-values': specifier: workspace:^ version: link:../../util/values @@ -9989,6 +9995,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -9996,6 +10005,18 @@ importers: '@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-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -10005,9 +10026,21 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-base': + specifier: workspace:^ + version: link:../../bundle/base '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-hmr': + specifier: workspace:^ + version: link:../../client/hmr + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store @@ -10032,15 +10065,30 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-web': + specifier: workspace:^ + version: link:../../client/web + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-web-app': + specifier: workspace:^ + version: link:../../bundle/web-app + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/tsconfig.base.json b/tsconfig.base.json index 6359e07899..245d733e82 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -249,6 +249,9 @@ // util/ folders are role-named without the util- prefix their npm names carry, // so these aliases stay hand-written like dsh-util-crypto above. "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], + // session-log-export lives under session-query/, so the generated aliases map only its + // bare name; the browser half needs this explicit subpath for source-mode dynamic imports. + "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], From d58c5455ddf7322a97b2eb5fe54184183614bfe6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:21:27 +0800 Subject: [PATCH 17/19] test(api): migrate Session and Workspace specs to assembled clients --- packages/api/session-controller/package.json | 2 + .../tests/client-apply.client.spec.ts | 276 +++--- .../tests/fake-api.client.ts | 31 +- .../tests/queue-store.client.spec.ts | 112 ++- .../tests/remote/bench.client.ts | 28 + .../tests/remote/history.client.ts | 48 ++ .../tests/remote/session.client.ts | 188 +++++ ...session-pending-submissions.client.spec.ts | 154 ++-- .../session-remote-rejection.client.spec.ts | 44 + .../tests/session.client.spec.ts | 796 ++++++++---------- .../api/workspace-controller/package.json | 2 + .../tests/remote/workspace.client.ts | 100 +++ .../tests/transport.client.spec.ts | 477 ++++------- pnpm-lock.yaml | 12 + 14 files changed, 1186 insertions(+), 1084 deletions(-) create mode 100644 packages/api/session-controller/tests/remote/bench.client.ts create mode 100644 packages/api/session-controller/tests/remote/history.client.ts create mode 100644 packages/api/session-controller/tests/remote/session.client.ts create mode 100644 packages/api/session-controller/tests/session-remote-rejection.client.spec.ts create mode 100644 packages/api/workspace-controller/tests/remote/workspace.client.ts diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index de77c242f3..9393149464 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -129,6 +129,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", @@ -137,6 +138,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index e1c28d8bce..53da35e928 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -1,168 +1,98 @@ -import { Context } from '@deepseek-ai/cordis' -import type { Fiber } from '@deepseek-ai/cordis' -import type { - ConnectionGeneration, - ConnectionHandle, -} from '@deepseek-ai/dsh-client-connection/client' -import { - RemoteStreamCarrierError, - RemoteStream, - type RemoteStreamOptions, -} from '@deepseek-ai/dsh-api-gateway/client' +/** + * Session Controller Client apply inside the assembled client: Remote events + * arriving as emit frames on the `$events` stream, the control stream over + * the real Connection, and Agent Context identity through the Typert registry. + */ +import type { Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -import { afterEach, describe, expect, it, vi } from 'vitest' -import * as SessionClient from '../src/client/index.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, describe, expect, vi, type MockInstance } from 'vitest' import { ClientSessions } from '../src/client/sessions/service.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' +import type { SessionListValue } from '../src/types.ts' -const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } } +const SELF = '@deepseek-ai/dsh-api-session-controller' +const ROSTER = webApp.closure([SELF]) +const it = createClientTest({ roster: ROSTER }) +const EVENTS = '$events' +const CONTROL = 'session/control' +const BASELINE = { type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } } +/** The first client boot pays the cold module transform of the cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 const sid = (value: string): SessionId => value as SessionId -type RemoteListener = (...args: never[]) => void - -interface Bench { - readonly ctx: Context - readonly api: FakeApiClient - readonly fiber: Fiber - readonly sessions: ClientSessions - dispatch(event: string, ...args: unknown[]): void - publishGeneration(generation: ConnectionGeneration | undefined): void -} - -const contexts = new Set() - -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks() - await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) - contexts.clear() }) -async function mount(initialGeneration?: ConnectionGeneration): Promise { - const ctx = new Context() - contexts.add(ctx) - await ctx.plugin(TypertRegistry) - const api = new FakeApiClient() - const remote = fakeRemote(api) - const listeners = new Map>() - const generationListeners = new Set<() => void>() - let generation = initialGeneration - const connection: ConnectionHandle = { - isLoopback: true, - generation: { - getSnapshot: () => generation, - subscribe: (listener) => { - generationListeners.add(listener) - return () => { generationListeners.delete(listener) } - }, - }, - state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - reconnect: () => {}, - registerGenerationSource: () => () => {}, - start: () => ({ stop: () => {} }), - } - ctx.reflect.provide('connection', connection) - ctx.reflect.provide('fileUpload', { - available: true, - post: () => Promise.reject(new Error('unexpected file upload')), - }) - ctx.reflect.provide('remote', { - ...remote, - $stream: (options: RemoteStreamOptions) => ( - new RemoteStream(connection, options) - ), - get $host() { - return { home: generation?.host.home, isLoopback: connection.isLoopback } - }, - $on: (event: string, listener: RemoteListener) => { - const eventListeners = listeners.get(event) ?? new Set() - eventListeners.add(listener) - listeners.set(event, eventListeners) - return () => { eventListeners.delete(listener) } - }, - }) - ctx.reflect.provide('remote.commands', remote.commands) - ctx.reflect.provide('remote.session', remote.session) - ctx.reflect.provide('remote.subagents', remote.subagents) - const fiber = ctx.plugin(SessionClient) - await fiber - const sessions = ctx.sessions as ClientSessions - return { - ctx, - api, - fiber, - sessions, - dispatch: (event, ...args) => { - for (const listener of listeners.get(event) ?? []) listener(...args as never[]) - }, - publishGeneration: (next) => { - generation = next - for (const listener of [...generationListeners]) listener() - }, - } +async function bench(start: () => Promise) { + const client = await start() + return { client, sessions: client.ctx.sessions as ClientSessions } } -async function flush(): Promise { - for (let index = 0; index < 12; index++) await Promise.resolve() +/** Deliver one Remote event the way the Host forwards it: an emit frame on the `$events` stream, consumed by the client. */ +async function emit(mock: RemoteMock, event: string, ...args: unknown[]): Promise { + mock.streams.push(EVENTS, { type: 'emit', event, args }) + await mock.streams.drained(EVENTS) +} + +function baselines(accept: MockInstance): number { + return accept.mock.calls.filter(([frame]) => (frame as { type: string }).type === 'baseline').length } describe('Session Controller Client apply', () => { - it('routes Session Remote Events and connection generations into the object layer', async () => { + it('routes Remote events from the $events stream into the object layer and runs handleConnected once per generation', async ({ mock, start }) => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') - const bench = await mount() - expect(connected).not.toHaveBeenCalled() + const { client, sessions } = await bench(start) + // The first generation's `connection/reset` already ran it; apply itself saw no Host yet. + await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() }) - bench.dispatch('api-session/added', { - sessionId: sid('session-1'), - updatedAt: 1, - running: false, - blank: true, - }) - await flush() - expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ - running: false, - updatedAt: 1, + await emit(mock, 'api-session/added', { sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true }) + await vi.waitFor(() => { + expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: false, updatedAt: 1 }) }) - bench.dispatch('api-session/status', sid('session-1'), true) - bench.dispatch('api-session/activity', sid('session-1'), 9) - bench.dispatch('api-session/error', sid('session-1'), 'agent failed') - await flush() - expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ - running: true, - updatedAt: 9, + await emit(mock, 'api-session/status', sid('session-1'), true) + await emit(mock, 'api-session/activity', sid('session-1'), 9) + await emit(mock, 'api-session/error', sid('session-1'), 'agent failed') + await vi.waitFor(() => { + expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: true, updatedAt: 9 }) }) expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed') - bench.dispatch('api-session/removed', sid('session-1')) - await flush() - expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() + await emit(mock, 'api-session/removed', sid('session-1')) + await vi.waitFor(() => { expect(sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() }) - bench.ctx.emit('connection/reset') - expect(connected).toHaveBeenCalledOnce() + client.connection.reconnect() + await mock.streams.opened(EVENTS, 2) + await vi.waitFor(() => { expect(connected).toHaveBeenCalledTimes(2) }) + }, COLD_BOOT_TIMEOUT_MS) + + it('runs handleConnected at apply when the Host is already connected, as a reload of the row does', async ({ start }) => { + const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') + const { client } = await bench(start) + await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() }) + await client.reload(SELF) + expect(connected).toHaveBeenCalledTimes(2) }) - it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { + it('accepts the control baseline, retries a carrier loss once, and reports a second opening snapshot as a protocol failure', async ({ mock, start }) => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - const bench = await mount(GENERATION) - await flush() + await start() + await vi.waitFor(() => { expect(baselines(accept)).toBe(1) }) + expect(accept).toHaveBeenCalledWith(BASELINE) - expect(accept).toHaveBeenCalledWith({ - type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, - }) + // One immediate retry while the Host is available reopens the stream, whose script pushes the baseline again. + mock.streams.fail(CONTROL, new RemoteStreamCarrierError('generation lost')) + await vi.waitFor(() => { expect(baselines(accept)).toBe(2) }) + expect(mock.log.streams(CONTROL)).toHaveLength(2) - bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) - - bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never) + mock.streams.push(CONTROL, BASELINE) await vi.waitFor(() => { expect(logged).toHaveBeenCalledWith( '[session-controller] control stream failed:', @@ -171,51 +101,61 @@ describe('Session Controller Client apply', () => { }) }) - it('materializes Host-addressed Agent scopes before the Session list arrives', async () => { - const bench = await mount() - const adapter = bench.ctx.typert.contexts.getClient('agent') + it('materializes Host-addressed Agent scopes before the Session list arrives', async ({ mock, start }) => { + const list = Promise.withResolvers>() + mock.remote.session.list.mockReturnValueOnce(list.promise) + const { client, sessions } = await bench(start) + const adapter = client.ctx.typert.contexts.getClient('agent') const first = adapter?.resolve(sid('agent-early')) expect(first).toBeDefined() - expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early')) + expect(sessions.scopeOf(first as Context)).toBe(sid('agent-early')) expect(adapter?.resolve(sid('agent-early'))).toBe(first) + list.resolve(ok({ items: [] })) + await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') }) }) - it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { - const bench = await mount(GENERATION) - await flush() - expect(bench.sessions.list.getSnapshot().phase).toBe('ready') + it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => { + const { client, sessions } = await bench(start) + await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') }) - bench.dispatch('api-session/added', { - sessionId: sid('agent-1'), - updatedAt: 1, - running: false, - blank: true, - }) - await flush() - const scoped = bench.sessions.scope(sid('agent-1')) - const adapter = bench.ctx.typert.contexts.getClient('agent') - expect(scoped).toBeDefined() - expect(adapter?.identity(bench.ctx)).toBeUndefined() - expect(adapter?.identity(scoped!)).toBe(sid('agent-1')) + await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true }) + await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() }) + const scoped = sessions.scope(sid('agent-1')) as Context + const adapter = client.ctx.typert.contexts.getClient('agent') + expect(adapter?.identity(client.ctx)).toBeUndefined() + expect(adapter?.identity(scoped)).toBe(sid('agent-1')) expect(adapter?.resolve(sid('agent-1'))).toBe(scoped) - await bench.fiber.dispose() - expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined() + await client.unload(SELF) + expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined() }) - it('waits for a Host generation before retrying the control stream', async () => { + it('waits for a Host generation before retrying the control stream', async ({ mock, start }) => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') - const bench = await mount() - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + const hostBack = Promise.withResolvers() + let opens = 0 + // The second $events generation stays unready until the test lets the Host answer. + mock.stream(EVENTS, (_args, stream) => { + opens += 1 + const ready = { type: 'ready', clientId: `mock-client-${String(opens)}`, host: { home: '/home/mock' } } + if (opens === 1) stream.push(ready) + else void hostBack.promise.then(() => { stream.push(ready) }) + }) + const client = await start() + await vi.waitFor(() => { expect(baselines(accept)).toBe(1) }) - bench.api.failStreams(new RemoteStreamCarrierError('offline')) - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + client.connection.reconnect() + await mock.streams.opened(EVENTS, 2) + expect(client.connection.generation.getSnapshot()).toBeUndefined() - bench.publishGeneration(GENERATION) - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) + mock.streams.fail(CONTROL, new RemoteStreamCarrierError('offline')) + await client.flush() + expect(baselines(accept)).toBe(1) + expect(mock.log.streams(CONTROL)).toHaveLength(1) + + hostBack.resolve(undefined) + await vi.waitFor(() => { expect(baselines(accept)).toBe(2) }) + expect(client.connection.generation.getSnapshot()).toMatchObject({ id: 2 }) }) }) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index c86546f630..b45e3c01f7 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -28,8 +28,7 @@ import { type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' -import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' -import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' +import { followSnapshot, pageThrough } from './remote/history.client.ts' const AVAILABLE_STREAM_CONNECTION = { generation: { @@ -363,11 +362,7 @@ export class FakeApiClient { if (!result.ok) return result return { ok: true, - value: { - ...result.value, - records: result.value.records - .filter(record => historyRecordLastSeq(record) <= request.throughSeq), - }, + value: pageThrough(result.value, request.throughSeq), } } @@ -388,27 +383,7 @@ export class FakeApiClient { }) if (!response.ok) throw response.error const page = response.value - const tail = page.records.at(-1) - const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail)) - yield { - type: 'snapshot', - header: { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 0, - isSeeded: false, - ...(request.address.kind === 'subagent' - ? { origin: 'subagent' as const, parentSession: request.address.parentSessionId } - : {}), - }, - cursor, - records: page.records.filter(record => historyRecordLastSeq(record) <= cursor), - hasMore: page.hasMore, - projections: page.projections ?? { asOfSeq: cursor, values: {} }, - ...request.assistantStream === true - ? { assistantStream: this.assistantStreamBaseline } - : {}, - } + yield followSnapshot(page, request, this.followCursor, this.assistantStreamBaseline) yield* stream.values } finally { stream.dispose() diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 573e4cfa7f..a6fd798f04 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -3,17 +3,23 @@ * change, reconnect re-baselining, pre-instantiation buffering, editable-text * projection, and snapshot reference stability. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' -import { Session } from '../src/client/sessions/session.ts' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' +import { sessionBench } from './remote/bench.client.ts' +import { pushEvent, sessionWorld } from './remote/session.client.ts' +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) const SID = 'fk-q1' as SessionId +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] const rid = (id: string): RpcId => id as RpcId const iid = (id: string): MessageId => id as MessageId @@ -42,23 +48,9 @@ function queueFrame(items: QueueFixture[]): Extract { - it('projects stable ids, flat previews, and complete text', () => { - const session = makeSession() + it('projects stable ids, flat previews, and complete text', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, ])) @@ -71,10 +63,10 @@ describe('Session queue snapshot intake', () => { preview: '第一条 排队 消息', text: '第一条 排队\n消息', }, ]) - }) + }, COLD_BOOT_TIMEOUT_MS) - it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => { - const session = makeSession() + it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', @@ -101,8 +93,8 @@ describe('Session queue snapshot intake', () => { ]) }) - it('caps previews at 200 code points and preserves the full editable text', () => { - const session = makeSession() + it('caps previews at 200 code points and preserves the full editable text', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const body = '长'.repeat(201) session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) const row = session.getSnapshot().queue[0] @@ -111,8 +103,8 @@ describe('Session queue snapshot intake', () => { expect(row?.text).toBe(body) }) - it('replaces content, order, and membership from each authoritative frame', () => { - const session = makeSession() + it('replaces content, order, and membership from each authoritative frame', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([ { id: 'q-1', body: 'one' }, { id: 'q-2', body: 'two' }, @@ -133,16 +125,16 @@ describe('Session queue snapshot intake', () => { expect(session.getSnapshot().queue).toEqual([]) }) - it('keeps the queue array reference stable across unrelated snapshot swaps', () => { - const session = makeSession() + it('keeps the queue array reference stable across unrelated snapshot swaps', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) const before = session.getSnapshot().queue session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) }) - it('retains steering placement and complete content in the same authoritative snapshot', () => { - const session = makeSession() + it('retains steering placement and complete content in the same authoritative snapshot', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([ { id: 'q-next', body: 'later' }, { id: 's-now', body: 'interrupt now', placement: 'steering' }, @@ -156,8 +148,8 @@ describe('Session queue snapshot intake', () => { ]) }) - it('hands off exactly one current occurrence when live steering becomes durable', async () => { - const { api, session } = makeBench() + it('hands off exactly one current occurrence when live steering becomes durable', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const message = createUserMessage({ content: text('same message'), @@ -175,7 +167,7 @@ describe('Session queue snapshot intake', () => { data: message, } satisfies SessionEvent - await api.pushFollow(SID, { type: 'event', event: durable as never }) + await pushEvent(mock, durable) await vi.waitFor(() => { expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) }) @@ -183,14 +175,14 @@ describe('Session queue snapshot intake', () => { session.handleControlFrame(queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, ])) - await api.pushFollow(SID, { type: 'event', event: durable as never }) + await pushEvent(mock, durable) await vi.waitFor(() => { expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) }) }) - it('hands off live steering when the agent claims it as a user message', async () => { - const { api, session } = makeBench() + it('hands off live steering when the agent claims it as a user message', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const message = createUserMessage({ content: text('claimed steering'), @@ -200,16 +192,13 @@ describe('Session queue snapshot intake', () => { { id: 's-claimed', body: '', placement: 'steering', message }, ])) - await api.pushFollow(SID, { - type: 'event', - event: { - seq: 0, - time: 1_700_000_000_000, - type: 'user/message', - surfaceOp: 'append', - data: message, - } as never, - }) + await pushEvent(mock, { + seq: 0, + time: 1_700_000_000_000, + type: 'user/message', + surfaceOp: 'append', + data: message, + } as never) await vi.waitFor(() => { expect(session.getSnapshot().queue).toEqual([]) @@ -218,9 +207,8 @@ describe('Session queue snapshot intake', () => { }) describe('queue operation transport', () => { - it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) + it('addresses the session.updateQueue RPC without optimistic local mutation', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue @@ -228,7 +216,7 @@ describe('queue operation transport', () => { .resolves.toEqual({ ok: true, value: { accepted: true } }) await expect(session.updateQueue(iid('q-op'), { kind: 'steer' })) .resolves.toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.updateQueue')).toEqual([ + expect(mock.log.requests('session/updateQueue')).toEqual([ { sessionId: SID, itemId: 'q-op', @@ -245,8 +233,8 @@ describe('queue operation transport', () => { }) describe('queue reconnect semantics', () => { - it('a control baseline clears stale state before a fresh update lands', () => { - const session = makeSession() + it('a control baseline clears stale state before a fresh update lands', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) session.replaceControl([]) expect(session.getSnapshot().queue).toEqual([]) @@ -254,16 +242,16 @@ describe('queue reconnect semantics', () => { expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { - const session = makeSession() + it('resync does not clear a baseline that raced ahead of the host connection signal', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) await session.resync() expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) }) - it('running-status changes never guess at queue retirement', () => { - const session = makeSession() + it('running-status changes never guess at queue retirement', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) session.handleRunning(true) session.handleRunning(false) @@ -272,15 +260,19 @@ describe('queue reconnect semantics', () => { }) describe('manager buffering of queue snapshots', () => { - it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = makeManager() + it('replays only the latest snapshot for an uninstantiated session', async ({ mock, start }) => { + mock.load(sessionWorld) + const { ctx: { remote } } = await start() + const manager = new SessionManager(remote) manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('a control baseline replaces the prior queue', () => { - const manager = makeManager() + it('a control baseline replaces the prior queue', async ({ mock, start }) => { + mock.load(sessionWorld) + const { ctx: { remote } } = await start() + const manager = new SessionManager(remote) manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items manager.handleControlFrame({ diff --git a/packages/api/session-controller/tests/remote/bench.client.ts b/packages/api/session-controller/tests/remote/bench.client.ts new file mode 100644 index 0000000000..6e0f10d569 --- /dev/null +++ b/packages/api/session-controller/tests/remote/bench.client.ts @@ -0,0 +1,28 @@ +/** Session objects owned by a client test's explicitly started Gateway assembly. */ +import { onTestFinished } from 'vitest' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { TestClient } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { Session, type SessionOptions } from '../../src/client/sessions/session.ts' +import { sessionWorld } from './session.client.ts' + +/** + * Create a Session after installing its default Remote responses. + * @param mock - this case's Remote responses. + * @param start - the fixture's lazy client startup. + * @param sessionId - Session identity. + * @param options - addressed child and parent availability, when applicable. + * @returns the Session, disposed at the end of the case. + */ +export async function sessionBench( + mock: RemoteMock, + start: () => Promise, + sessionId: SessionId, + options: SessionOptions = {}, +): Promise { + mock.load(sessionWorld) + const client = await start() + const session = new Session(sessionId, client.ctx.remote, options) + onTestFinished(() => session.dispose()) + return session +} diff --git a/packages/api/session-controller/tests/remote/history.client.ts b/packages/api/session-controller/tests/remote/history.client.ts new file mode 100644 index 0000000000..3a194f4e99 --- /dev/null +++ b/packages/api/session-controller/tests/remote/history.client.ts @@ -0,0 +1,48 @@ +/** Pure history responses shared by the local Remote fake and assembled Session tests. */ +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' +import type { + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline, +} from '../../src/types.ts' +import { historyRecordLastSeq } from '../../src/client/sessions/history-records.ts' + +/** + * Cut a history page at the Host cursor, preserving its other fields. + * @param page - scripted history page. + * @param throughSeq - inclusive final sequence. + * @returns the page without records beyond the cursor. + */ +export function pageThrough(page: SessionPage, throughSeq: number): SessionPage { + return { ...page, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } +} + +/** + * Build the opening follow frame for a Session or addressed child. + * @param page - history and optional projection baseline. + * @param request - addressed follow request. + * @param cursor - opening cursor; defaults to the history tail, or -1 when empty. + * @param assistantStream - baseline included only when the request opts in. + * @returns the opening frame. + */ +export function followSnapshot( + page: SessionPage & { readonly projections?: SessionProjectionBaseline }, + request: SessionFollowRequest, + cursor = page.records.length === 0 ? -1 : historyRecordLastSeq(page.records.at(-1)!), + assistantStream: SessionAssistantStreamBaseline = { revision: 0 }, +): Extract { + const { address } = request + return { + type: 'snapshot', + header: { + version: SESSION_FORMAT_VERSION, + id: address.kind === 'session' ? address.sessionId : address.childSessionId, + createdAt: 0, + isSeeded: false, + ...(address.kind === 'subagent' ? { origin: 'subagent', parentSession: address.parentSessionId } : {}), + }, + cursor, + records: pageThrough(page, cursor).records, + hasMore: page.hasMore, + projections: page.projections ?? { asOfSeq: cursor, values: {} }, + ...(request.assistantStream === true ? { assistantStream } : {}), + } +} diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts new file mode 100644 index 0000000000..d2e0b3c135 --- /dev/null +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -0,0 +1,188 @@ +/** + * The Remote side of one Session under test: default answers for every + * `session/*` and `subagents/*` endpoint a `Session` calls, builders for + * the two history-shaped answers, the `session/follow` opening snapshot and + * the `session/page` page, both derived from event lists the way the Host + * derives them from its log, and builders for the `session/control` queue + * frame and the attachment references the Host's log carries. + */ +import { AttachmentId, type FileAttachmentRef, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRuleFn } from '@deepseek-ai/dsh-remote-mock' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { + SessionControlFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionPageRequest, + SessionQueuedItem, SessionRequestId, +} from '../../src/types.ts' +import { entries, historyValue } from '../event-script.client.ts' +import { followSnapshot, pageThrough } from './history.client.ts' + +export { followSnapshot } from './history.client.ts' + +/** Endpoints a Session opens and calls for its history. */ +export const FOLLOW = 'session/follow' +export const PAGE = 'session/page' + +/** A history answer as the Host returns it, possibly still pending. */ +export type HistoryAnswer = RemoteResult | Promise> +/** A history answer, or a function of the request that produces one. */ +export type HistorySource = HistoryAnswer | ((request: Request) => HistoryAnswer) + +/** + * The failure branch of a Remote result. + * @param error - the owner-declared failure. + * @returns the result. + */ +export function err(error: RemoteFailure): RemoteResult { + return { ok: false, error } +} + +/** + * The Host's history answer for `events`. + * @param events - events in seq order. + * @param hasMore - whether older history exists. + * @returns the success result. + */ +export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { + return ok(historyValue(events, hasMore)) +} + +/** + * One live event frame of a follow stream. + * @param event - the event. + * @returns the frame. + */ +export function frame(event: SessionEvent): SessionFollowFrame { + return entries([event])[0]! +} + +/** + * Deliver one live event to every open follow stream and wait until the client has consumed it. + * @param mock - the mock holding the streams. + * @param event - the event. + */ +export async function pushEvent(mock: RemoteMock, event: SessionEvent): Promise { + mock.streams.push(FOLLOW, frame(event)) + await mock.streams.drained(FOLLOW) +} + +/** + * `session/follow` script: the opening snapshot built from the history answer, + * then open for pushes. A failed answer fails the stream with its error; a + * rejected one fails it with the rejection. + * @param history - history answer or a function of the follow request. + * @param options - `cursor` overrides the snapshot cursor. + * @returns the script. + */ +export function followScript(history: HistorySource, options: { cursor?: number } = {}): StreamScript { + return async ([request], stream) => { + const follow = request as SessionFollowRequest + const result = await answer(history, follow) + if (!result.ok) { + stream.fail(result.error) + return + } + stream.push(followSnapshot(result.value, follow, options.cursor)) + } +} + +/** + * `session/page` rule: the history answer cut at the request's `throughSeq`. + * @param history - history answer or a function of the page request. + * @returns the rule. + */ +export function pageRule( + history: HistorySource, +): UnaryRuleFn>> { + return async (page) => { + const result = await answer(history, page) + if (!result.ok) return result + return ok(pageThrough(result.value, page.throughSeq)) + } +} + +function answer(history: HistorySource, request: Request): HistoryAnswer { + return typeof history === 'function' ? history(request) : history +} + +/** + * An image attachment reference as the Host's durable log carries it. + * @param id - opaque attachment id. + * @returns the reference. + */ +export function imageRef(id: string): ImageAttachmentRef { + return { attachmentId: AttachmentId(id), mediaType: 'image/png', bytes: 1, width: 2, height: 2 } +} + +/** + * A file attachment reference as the Host's durable log carries it. + * @param id - opaque attachment id. + * @param name - display filename. + * @returns the reference. + */ +export function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { + return { attachmentId: AttachmentId(id), name, bytes: 3 } +} + +/** One pending inbox occurrence of a queue frame; the message id defaults to the item id. */ +export interface QueueItemFixture { + readonly id: string + readonly placement?: SessionQueuedItem['placement'] + /** Prompt identity the Host copied from the queued message's user source. */ + readonly rpcId?: SessionRequestId + /** Queued message content; ignored when `message` is given. */ + readonly content?: readonly ContentBlock[] + /** The queued message itself, for a test that also lands it durably (the queue mirror correlates by `id`). */ + readonly message?: { readonly id: MessageId; readonly content: readonly ContentBlock[] } +} + +/** + * One pending inbox occurrence as the Host lists it. + * @param fixture - item fields. + * @returns the item. + */ +export function queueItem(fixture: QueueItemFixture): SessionQueuedItem { + const message = fixture.message ?? { id: fixture.id as MessageId, content: fixture.content ?? [] } + return { + id: fixture.id as MessageId, + placement: fixture.placement ?? 'queued', + ...(fixture.rpcId === undefined ? {} : { rpcId: fixture.rpcId }), + // The Host serializes queued content to JSON; the blocks cross unchanged. + message: { id: message.id, content: message.content as unknown as SessionQueuedItem['message']['content'] }, + } +} + +/** + * One authoritative queue frame of the `session/control` stream. + * @param sessionId - the addressed Session. + * @param items - the complete pending queue. + * @returns the frame. + */ +export function queueFrame( + sessionId: SessionId, + items: readonly QueueItemFixture[], +): Extract { + return { type: 'queue', sessionId, items: items.map(queueItem) } +} + +/** Default answers: every command accepted, an empty history, one attachment of one zero byte. */ +export const sessionWorld: RemoteTable = { + unary: { + 'session/prompt': ok({ accepted: true }), + 'session/cancel': ok({ accepted: true }), + 'session/updateQueue': ok({ accepted: true }), + 'session/rename': ok({ title: 'fk-renamed', seq: 0 }), + 'session/attachment': ok({ + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: 'AA==', + }), + 'session/page': pageRule(ok({ records: [], hasMore: false })), + 'subagents/prompt': ok({ messageId: 'fake-message' }), + 'subagents/interruptByParent': ok({ accepted: true }), + }, + stream: { + 'session/follow': followScript(ok({ records: [], hasMore: false })), + }, +} diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 46cfe1c049..b88f869367 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -1,40 +1,34 @@ -/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */ +/** + * Local submission echoes: synchronous insertion, observed/failed retirement, + * and settlement callbacks. Prompts and the follow stream cross the assembled + * Gateway client and are answered by endpoint name. + */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { Session } from '../src/client/sessions/session.ts' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' -import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' -import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' -import { historyValue } from './event-script.client.ts' +import type { SessionRequestId } from '../src/types.ts' +import { sessionBench } from './remote/bench.client.ts' +import { + FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent, queueFrame, +} from './remote/session.client.ts' +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) const SID = 'fk-s1' as SessionId +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 afterEach(() => { vi.unstubAllGlobals() }) -function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, fakeRemote(api)) } -} - -function imageRef(id: string): ImageAttachmentRef { - return { - attachmentId: id, - mediaType: 'image/png', - bytes: 1, - width: 2, - height: 2, - } as unknown as ImageAttachmentRef -} - -function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { - return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef -} - type AttachmentRef = ImageAttachmentRef | FileAttachmentRef function attachmentBlock(attachment: AttachmentRef) { @@ -60,16 +54,9 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly At } as unknown as SessionEvent } -function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem { - return { - id: 'm-queued' as SessionQueuedItem['id'], - placement: 'queued', - rpcId, - message: { - id: 'm-queued' as SessionQueuedItem['id'], - content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'], - }, - } +/** The Host's queue holding one occurrence of the prompt `rpcId`. */ +function queuedFrame(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) { + return queueFrame(SID, [{ id: 'm-queued', rpcId, content: refs.map(attachmentBlock) }]) } /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ @@ -78,8 +65,8 @@ function settleFrames(): Promise { } describe('beginSubmission', () => { - it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => { - const { session } = makeSession() + it('inserts the echo synchronously and flips the engaging edge before any prompt call', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) const handle = session.beginSubmission({ mode: 'queue', @@ -97,10 +84,11 @@ describe('beginSubmission', () => { type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, }], }]) - }) + expect(mock.log.requests()).toEqual([]) + }, COLD_BOOT_TIMEOUT_MS) - it('derives and captures the echo placement from running state and delivery mode', () => { - const { session } = makeSession() + it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] }) session.handleRunning(true) session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] }) @@ -113,8 +101,8 @@ describe('beginSubmission', () => { ]) }) - it('abandon retires the echo as failed exactly once', () => { - const { session } = makeSession() + it('abandon retires the echo as failed exactly once', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ mode: 'queue', @@ -130,9 +118,9 @@ describe('beginSubmission', () => { }) describe('prompt-coupled retirement', () => { - it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) + it('a rejected identified prompt retires its echo immediately alongside promptError', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ mode: 'queue', @@ -143,20 +131,20 @@ describe('prompt-coupled retirement', () => { const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) expect(result.ok).toBe(false) expect(session.getSnapshot().pendingSubmissions).toEqual([]) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } }) expect(retirements).toEqual([{ reason: 'failed' }]) }) - it('sends the echo identity as the prompt requestId', async () => { - const { api, session } = makeSession() + it('sends the echo identity as the prompt requestId', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) - expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) + expect(mock.log.requests('session/prompt')).toMatchObject([{ requestId: handle.requestId, sessionId: SID }]) }) - it('an unidentified prompt failure leaves registered echoes alone', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) + it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) @@ -164,9 +152,8 @@ describe('prompt-coupled retirement', () => { }) describe('observed retirement', () => { - it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ @@ -176,7 +163,7 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs)) // Synchronously after the append the echo is still in the snapshot; the // render-time dedupe owns the overlap frame. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) @@ -185,8 +172,8 @@ describe('observed retirement', () => { expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) }) - it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { - const { session } = makeSession() + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const retirements: PendingSubmissionRetirement[] = [] session.handleRunning(true) const handle = session.beginSubmission({ @@ -196,7 +183,7 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] - session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + session.handleControlFrame(queuedFrame(handle.requestId, refs)) await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) @@ -204,9 +191,8 @@ describe('observed retirement', () => { expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) }) - it('retires a mixed echo with durable references in original selection order', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const file = fileRef('file-1') @@ -221,23 +207,22 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('image-1'), file, imageRef('image-2')] - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs)) await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) }) - it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { - const { api, session } = makeSession() + it('a full-window install (reconnect resync) retires echoes observed in the window', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] }) - api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)]))) + mock.stream(FOLLOW, followScript(history([promptEvent(SessionSeq(12), handle.requestId)]))) await session.open() await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) }) - it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ @@ -246,15 +231,14 @@ describe('observed retirement', () => { attachments: [], onRetire: retirement => retirements.push(retirement), }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId)) handle.abandon() await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) }) - it('retires once when the queue and durable event report the same request id', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('retires once when the queue and durable event report the same request id', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ @@ -263,28 +247,23 @@ describe('observed retirement', () => { attachments: [], onRetire: retirement => retirements.push(retirement), }) - session.handleControlFrame({ - type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])], - }) - await api.pushFollow(SID, { - type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never, - }) + session.handleControlFrame(queuedFrame(handle.requestId)) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId)) await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) expect(session.getSnapshot().pendingSubmissions).toEqual([]) }) - it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => { + it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.open() const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { frames.push(fn) return frames.length }) - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) - await session.open() const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId)) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) frames[0]?.(0) @@ -293,9 +272,8 @@ describe('observed retirement', () => { }) describe('disposal', () => { - it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] const observed = session.beginSubmission({ @@ -310,7 +288,7 @@ describe('disposal', () => { attachments: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), observed.requestId)) await session.dispose() await settleFrames() expect(retirements).toEqual([ diff --git a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts new file mode 100644 index 0000000000..a6e7a274aa --- /dev/null +++ b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts @@ -0,0 +1,44 @@ +/** + * A rejection from the Remote face, which the generated client raises only for + * an assembly fault (wrong arity, an unmounted method, a withdrawn + * contribution), propagates out of the Session command unchanged and records no + * prompt error. Carrier failures never reject: they arrive as folded results, + * covered by session.client.spec.ts. + */ +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { Session } from '../src/client/sessions/session.ts' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' + +const SID = 'fk-s1' as SessionId +const PARENT = 'fk-parent' as SessionId + +/** Every command rejects with `message`; nothing else on the face is reachable from prompt or cancel. */ +function rejecting(message: string): SessionRemotes { + const reject = (): Promise => Promise.reject(new Error(message)) + return { + $stream: () => { throw new Error('unused') }, + commands: { execute: reject }, + session: { prompt: reject, cancel: reject }, + subagents: { list: reject, prompt: reject, interruptByParent: reject }, + } as unknown as SessionRemotes +} + +describe('Session over a rejecting Remote face', () => { + it('propagates the rejection from prompt and cancel and records no prompt error', async () => { + const session = new Session(SID, rejecting('assembly fault')) + await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') + await expect(session.cancel()).rejects.toThrow('assembly fault') + expect(session.getSnapshot().promptError).toBeNull() + }) + + it('propagates the rejection from a subagent continuation prompt and interrupt', async () => { + const session = new Session(SID, rejecting('assembly fault'), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') + await expect(session.cancel()).rejects.toThrow('assembly fault') + expect(session.getSnapshot().promptError).toBeNull() + }) +}) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index c5a62a27df..9607c670f0 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,36 +1,35 @@ -/** Session object lifecycle, event-window transport, commands, and resync behavior. */ +/** + * Session object lifecycle, event-window transport, commands, and resync + * behavior, driven through the assembled client: every Remote call a Session + * makes crosses the roster's own Connection through the tier's `remote.` + * proxies and is answered by endpoint name. + */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { JUMP_PAGE_MESSAGES, Session, type SessionOptions } from '../src/client/sessions/session.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { JUMP_PAGE_MESSAGES, type Session } from '../src/client/sessions/session.ts' +import { SessionEventStream } from '../src/client/transport.ts' +import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' +import { sessionBench } from './remote/bench.client.ts' +import { + FOLLOW, PAGE, err, followScript, followSnapshot, frame, history, pageRule, pushEvent, +} from './remote/session.client.ts' +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId - -afterEach(() => { - vi.unstubAllGlobals() -}) -function makeSession( - api = new FakeApiClient(), - options: SessionOptions = {}, -): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, fakeRemote(api), options) } -} - -function follow( - api: FakeApiClient, - event: SessionEvent, -): Promise { - return api.pushFollow(SID, { - type: 'event', - event: event as never, - }) -} +const ADDRESS = { kind: 'session', sessionId: SID } as const +const TIME_ZONE = new Intl.DateTimeFormat().resolvedOptions().timeZone +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 function windowEntries(session: Session) { return session.eventSource.getSnapshot().entries @@ -40,23 +39,19 @@ function eventSeqs(session: Session): number[] { return windowEntries(session).map(entry => entry.event.seq) } -function histResponse(events: SessionEvent[], hasMore = false) { - return Promise.resolve(ok(historyValue(events, hasMore))) -} - describe('Session open', () => { - it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { - const { session } = makeSession() + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false }) session.handleRunning(true) expect(session.getSnapshot()).toMatchObject({ blank: false, running: true }) - }) + }, COLD_BOOT_TIMEOUT_MS) - it('installs the tail page: cold → loading → open with window and nodes in place', async () => { - const { api, session } = makeSession() + it('installs the tail page: cold → loading → open with window and nodes in place', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const page = plainTurn(SessionSeq(10), 3, '问', '答') - api.onHistory = () => histResponse(page, true) + mock.stream(FOLLOW, followScript(history(page, true))) expect(session.getSnapshot().openState).toBe('cold') const opening = session.open() expect(session.getSnapshot().openState).toBe('loading') @@ -68,103 +63,96 @@ describe('Session open', () => { expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' }) }) - it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => { - const { api, session } = makeSession() + it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await Promise.all([session.open(), session.open()]) await session.open() - expect(api.callsOf('session.follow')).toHaveLength(1) - expect(api.callsOf('session.history')).toEqual([]) + expect(mock.log.requests(FOLLOW)).toHaveLength(1) + expect(mock.log.requests(PAGE)).toEqual([]) }) - it('lands an error result in openState=error with the Remote failure kept', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID }))) + it('lands an error result in openState=error with the Remote failure kept', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(err(new RemoteError('session/not-found', 'gone', { sessionId: SID })))) await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') expect(snapshot.openError?.code).toBe('session/not-found') }) - it('lands exhausted carrier retries in openState=error as gateway/internal', async () => { - const { api, session } = makeSession() + it('lands exhausted carrier retries in openState=error as gateway/internal', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) // Two consecutive carrier losses before any opening is accepted exhaust the // Gateway's retry budget; the escaping failure crosses the stream boundary marked. - api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down')) + mock.stream(FOLLOW, followScript(() => Promise.reject(new RemoteStreamCarrierError('history carrier down')))) await session.open() expect(session.getSnapshot().openState).toBe('error') expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'history carrier down', }) - expect(api.followStarts).toHaveLength(2) + expect(mock.log.requests(FOLLOW)).toHaveLength(2) }) - it('lands a Gateway-marked stream failure in openState=error', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.reject(new Error('socket died')) + it('lands a Gateway-marked stream failure in openState=error', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(() => Promise.reject(new Error('socket died')))) await session.open() expect(session.getSnapshot().openState).toBe('error') expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' }) }) - it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { - const { api, session } = makeSession() - const gate = deferred>>() - api.onHistory = () => gate.promise - const opening = session.open() - // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail. + it('stitches live frames landing right behind the opening snapshot, dropping the page overlap', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const gate = Promise.withResolvers() const page = plainTurn(SessionSeq(10), 0, '早', '安') - const deliveries = [ - follow(api, ev.turnStart(SessionSeq(15), 1)), - follow(api, ev.user(SessionSeq(16), '插进来的')), - ] - gate.resolve(ok({ - records: entries(page) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await Promise.all([opening, ...deliveries]) - const seqs = eventSeqs(session) + mock.stream(FOLLOW, async ([request], stream) => { + stream.push(followSnapshot(await gate.promise, request as SessionFollowRequest)) + // Two live frames follow the snapshot before the opening settles; seq 15 overlaps its tail. + stream.push(frame(ev.turnStart(SessionSeq(15), 1))) + stream.push(frame(ev.user(SessionSeq(16), '插进来的'))) + }) + const opening = session.open() + gate.resolve(historyValue(page)) + await opening + await mock.streams.drained(FOLLOW) // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. - expect(seqs).toEqual([10, 11, 12, 13, 14, 15, 16]) + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16]) }) }) - describe('live event path', () => { - async function opened(events: SessionEvent[] = plainTurn(SessionSeq(0), 0, 'a', 'b')) { - const { api, session } = makeSession() - api.onHistory = () => histResponse(events) + async function opened(mock: RemoteMock, start: () => Promise, events: SessionEvent[] = plainTurn(SessionSeq(0), 0, 'a', 'b')) { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(events))) await session.open() - return { api, session } + return session } - it('drops replayed frames at or below the window tail', async () => { - const { api, session } = await opened() + it('drops replayed frames at or below the window tail', async ({ mock, start }) => { + const session = await opened(mock, start) const before = session.eventSource.getSnapshot() - await follow(api, ev.user(SessionSeq(3), '重放')) + await pushEvent(mock, ev.user(SessionSeq(3), '重放')) expect(session.eventSource.getSnapshot()).toBe(before) }) - it('keeps the authoritative host blank bit across unrelated log events', async () => { - const { api, session } = await opened([]) + it('keeps the authoritative host blank bit across unrelated log events', async ({ mock, start }) => { + const session = await opened(mock, start, []) session.handleBlank(true) - await Promise.all([ - follow(api, ev.commandRun(SessionSeq(0), 'cmd-perm', 'permission', ' danger-full-access')), - follow(api, ev.commandDone(SessionSeq(1), 'cmd-perm', 'success', 'preset danger-full-access')), - ]) + await pushEvent(mock, ev.commandRun(SessionSeq(0), 'cmd-perm', 'permission', ' danger-full-access')) + await pushEvent(mock, ev.commandDone(SessionSeq(1), 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() expect(eventSeqs(session)).toEqual([0, 1]) expect(snapshot.blank).toBe(true) }) - it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { - const { api, session } = await opened(plainTurn(SessionSeq(0), 0, 'a', 'b')) // tail seq = 5 + it('repairs a seq gap by repulling the tail page instead of appending a hole', async ({ mock, start }) => { + const session = await opened(mock, start, plainTurn(SessionSeq(0), 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')] - api.onHistory = () => histResponse(repaired) - // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. - await follow(api, ev.assistant(SessionSeq(9), 1, 'd')) + mock.remote.session.page.mockImplementation(pageRule(history(repaired))) + // seq 9 with tail 5 → gap; the event detours to the buffer and one page refetch fires. + await pushEvent(mock, ev.assistant(SessionSeq(9), 1, 'd')) await vi.waitFor(() => { - expect(api.callsOf('session.history')).toHaveLength(1) + expect(mock.log.requests(PAGE)).toHaveLength(1) }) await vi.waitFor(() => { expect(eventSeqs(session)).toEqual( @@ -175,31 +163,30 @@ describe('live event path', () => { }) describe('paging', () => { - it('prepends an older page and keeps seq continuity', async () => { + it('prepends an older page and keeps seq continuity', async ({ mock, start }) => { const older = plainTurn(SessionSeq(0), 0, '旧问', '旧答') const newer = plainTurn(SessionSeq(6), 1, '新问', '新答') - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(newer, true) - : histResponse(older, false) + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newer, true))) + mock.remote.session.page.mockImplementation(pageRule(history(older))) await session.open() await session.loadOlder() const snapshot = session.getSnapshot() - expect(api.callsOf('session.follow')).toHaveLength(1) - expect(api.callsOf('session.history')).toMatchObject([ - { sessionId: SID, throughSeq: 11, beforeSeq: 6 }, + expect(mock.log.requests(FOLLOW)).toHaveLength(1) + expect(mock.log.requests(PAGE)).toMatchObject([ + { address: ADDRESS, throughSeq: 11, beforeSeq: 6 }, ]) expect(snapshot.hasMore).toBe(false) expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq)) }) - it('installs a page without interpreting business replacement metadata', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([ + it('installs a page without interpreting business replacement metadata', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history([ ev.compactSummary(SessionSeq(80), '窗外范围的摘要', SessionSeq(3), SessionSeq(40)), ev.compactCheckpoint(SessionSeq(81), SessionSeq(80), SessionSeq(3), SessionSeq(40)), ev.user(SessionSeq(82), '压缩后的新问题'), - ], true) + ], true))) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { await session.open() @@ -212,11 +199,10 @@ describe('paging', () => { } }) - it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(SessionSeq(10), 1, '新', '页'), true) - : histResponse(plainTurn(SessionSeq(0), 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole + it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(10), 1, '新', '页'), true))) + mock.remote.session.page.mockImplementation(pageRule(history(plainTurn(SessionSeq(0), 0, '断', '层'), true))) // tail seq 5, but baseSeq is 10 → hole const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { await session.open() @@ -230,174 +216,164 @@ describe('paging', () => { } }) - it('loadThrough pages repeatedly until the window covers the target seq', async () => { + it('loadThrough pages repeatedly until the window covers the target seq', async ({ mock, start }) => { const oldest = plainTurn(SessionSeq(0), 0, '最旧问', '最旧答') const middle = plainTurn(SessionSeq(6), 1, '中问', '中答') const newest = plainTurn(SessionSeq(12), 2, '新问', '新答') - const { api, session } = makeSession() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) return histResponse(newest, true) - return payload.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false) - } + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() - const gate = deferred>>() - api.onHistory = (payload) => { - api.onHistory = payload2 => payload2.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false) - void payload - return gate.promise - } + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(request => (request.beforeSeq === 12 ? history(middle, true) : history(oldest)))) + mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) expect(session.getSnapshot().loadingOlder).toBe(true) - gate.resolve(ok(historyValue(middle, true))) + gate.resolve(history(middle, true)) await jump const snapshot = session.getSnapshot() expect(snapshot.loadingOlder).toBe(false) expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq)) - expect(api.callsOf('session.history')).toMatchObject([ + expect(mock.log.requests(PAGE)).toMatchObject([ { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES }, { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES }, ]) }) - it('loadThrough is a no-op when the window already covers the target or the session is not open', async () => { - const { api, session } = makeSession() + it('loadThrough is a no-op when the window already covers the target or the session is not open', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.loadThrough(SessionSeq(0)) // cold: no-op - expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) + expect(mock.log.requests()).toEqual([]) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const calls = api.calls.length + const sent = mock.log.requests().length await session.loadThrough(SessionSeq(6)) // baseSeq is already 6 await session.loadThrough(SessionSeq(9)) // inside the window - expect(api.calls.length).toBe(calls) + expect(mock.log.requests()).toHaveLength(sent) }) - it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async () => { + it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async ({ mock, start }) => { const oldest = plainTurn(SessionSeq(0), 0, 'a', 'b') const middle = plainTurn(SessionSeq(6), 1, 'c', 'd') - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true) + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() - const gate = deferred>>() - api.onHistory = () => { - api.onHistory = () => histResponse(oldest, false) - return gate.promise - } + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(history(oldest))) + mock.remote.session.page.mockReturnValueOnce(gate.promise) const first = session.loadThrough(SessionSeq(6)) const second = session.loadThrough(SessionSeq(0)) - gate.resolve(ok(historyValue(middle, true))) + gate.resolve(history(middle, true)) await Promise.all([first, second]) expect(eventSeqs(session)).toEqual([ ...[...oldest, ...middle].map(event => event.seq), 12, 13, 14, 15, 16, 17, ]) - expect(api.callsOf('session.history')).toHaveLength(2) + expect(mock.log.requests(PAGE)).toHaveLength(2) }) - it('loadThrough refused by a busy pager leaves no target behind for later jumps', async () => { + it('loadThrough refused by a busy pager leaves no target behind for later jumps', async ({ mock, start }) => { const middle = plainTurn(SessionSeq(6), 1, 'c', 'd') - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true) + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() // A plain single-page pull holds the busy flag while the jump is refused. - const gate = deferred>>() - api.onHistory = () => gate.promise + const gate = Promise.withResolvers>() + mock.remote.session.page.mockReturnValueOnce(gate.promise) const older = session.loadOlder() await session.loadThrough(SessionSeq(0)) // refused: must not park seq 0 anywhere - gate.resolve(ok(historyValue(middle, true))) + gate.resolve(history(middle, true)) await older // A later jump to a nearer seq pages exactly to it — a leaked 0 target // would keep pulling three-event pages all the way to the head. - api.onHistory = (payload) => { - const start = ((payload as { beforeSeq?: number }).beforeSeq ?? 0) - 3 - return histResponse( + mock.remote.session.page.mockImplementation(pageRule((request) => { + const start = (request.beforeSeq ?? 0) - 3 + return history( [ev.user(SessionSeq(start), `u${String(start)}`), ev.user(SessionSeq(start + 1), `u${String(start + 1)}`), ev.user(SessionSeq(start + 2), `u${String(start + 2)}`)], start > 0, ) - } + })) await session.loadThrough(SessionSeq(4)) // Covered at seq 3 (≤ 4) after one page; a leaked 0 target would add a // third call at beforeSeq 3 and pull the head to 0. - expect(api.callsOf('session.history').map(call => (call as { beforeSeq?: number }).beforeSeq)) - .toEqual([12, 6]) + expect((mock.log.requests(PAGE) as SessionPageRequest[]).map(request => request.beforeSeq)).toEqual([12, 6]) expect(eventSeqs(session)[0]).toBe(3) }) - it('loadThrough stops paging when the event stream generation moves mid-loop', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) + it('loadThrough stops paging when the event stream generation moves mid-loop', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - const gate = deferred>>() - api.onHistory = () => gate.promise + const gate = Promise.withResolvers>() + mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) - // The address is rebuilt while the first page is in flight. - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) + // The address is rebuilt while the first page is in flight; the same follow script serves the new generation. const rebuilt = session.resync() - gate.resolve(ok(historyValue(plainTurn(SessionSeq(6), 1, 'c', 'd'), true))) + gate.resolve(history(plainTurn(SessionSeq(6), 1, 'c', 'd'), true)) await jump await rebuilt // The stale loop must not page the new generation toward its old target: - // history calls are the gated page and the resync tail only. - expect(api.callsOf('session.history')).toHaveLength(1) + // the gated page is the only page call, and the resync opened a second follow. + expect(mock.log.requests(PAGE)).toHaveLength(1) + expect(mock.log.requests(FOLLOW)).toHaveLength(2) expect(session.getSnapshot().loadingOlder).toBe(false) }) - it('loadThrough stops on a page that makes no progress instead of looping', async () => { - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) - : histResponse([], true) // empty page still claiming more history + it('loadThrough stops on a page that makes no progress instead of looping', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) + mock.remote.session.page.mockImplementation(pageRule(history([], true))) // empty page still claiming more history await session.open() await session.loadThrough(SessionSeq(0)) expect(session.getSnapshot().loadingOlder).toBe(false) - expect(api.callsOf('session.history')).toHaveLength(1) + expect(mock.log.requests(PAGE)).toHaveLength(1) }) - it('loadThrough fails soft on a thrown page and clears its busy state', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) + it('loadThrough fails soft on a page failure and clears its busy state: a folded carrier throw quietly, a local throw with console.error', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - api.onHistory = () => Promise.reject(new Error('page wire down')) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const prepend = vi.spyOn(SessionEventStream.prototype, 'prepend') try { + mock.remote.session.page.mockImplementation(() => Promise.reject(new Error('page wire down'))) await session.loadThrough(SessionSeq(0)) - expect(errorSpy).toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() // the proxy folds the throw into a Remote failure, as the generated client would + expect(session.getSnapshot().loadingOlder).toBe(false) + prepend.mockRejectedValueOnce(new Error('local fault')) + await session.loadThrough(SessionSeq(0)) + expect(errorSpy).toHaveBeenCalledWith('[session-controller] loadThrough failed:', expect.any(Error)) expect(session.getSnapshot().loadingOlder).toBe(false) } finally { + prepend.mockRestore() errorSpy.mockRestore() } }) - it('ignores loadOlder while one is in flight (single request)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) + it('ignores loadOlder while one is in flight (single request)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const gate = deferred>>() - api.onHistory = () => gate.promise + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(gate.promise)) const first = session.loadOlder() const second = session.loadOlder() - gate.resolve(ok({ - records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) + gate.resolve(history(plainTurn(SessionSeq(0), 0, 'a', 'b'))) await Promise.all([first, second]) - expect(api.callsOf('session.follow')).toHaveLength(1) - expect(api.callsOf('session.history')).toHaveLength(1) + expect(mock.log.requests(FOLLOW)).toHaveLength(1) + expect(mock.log.requests(PAGE)).toHaveLength(1) }) }) describe('prompt and cancel errors', () => { - it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + const CHILD = { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' } as const + + it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer') @@ -406,54 +382,38 @@ describe('prompt and cancel errors', () => { expect(prompted).toEqual({ ok: true, value: { accepted: true } }) expect(steered).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.follow')).toEqual([ - { - address: { - kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', - }, - assistantStream: true, - maxMessages: 50, - }, + expect(mock.log.requests(FOLLOW)).toEqual([ + { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 50 }, ]) - expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagents.prompt')).toEqual([ + expect(mock.log.requests(PAGE)).toEqual([]) + // The prompt mode crosses the wire as the request's delivery. + expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - parentSessionId: PARENT, childSessionId: SID, - mode: 'continuable', + ...CHILD, delivery: 'queue', content: [{ type: 'text', text: '继续' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }, { requestId: expect.any(String) as unknown as string, - parentSessionId: PARENT, childSessionId: SID, - mode: 'continuable', + ...CHILD, delivery: 'steer', content: [{ type: 'text', text: '现在处理' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }, ]) - expect(api.callsOf('subagents.interruptByParent')).toEqual([ - { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, - ]) - expect(api.callsOf('session.history')).toEqual([]) - expect(api.callsOf('session.prompt')).toEqual([]) - expect(api.callsOf('session.cancel')).toEqual([]) + // Positional parameters cross the wire as positional args. + expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.requests('session/prompt')).toEqual([]) + expect(mock.log.requests('session/cancel')).toEqual([]) // A successful interrupt leaves no stop error behind. expect(session.getSnapshot().promptError).toBeNull() - expect(session.getSnapshot().subagent).toEqual({ - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) }) - it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() const content = [ { type: 'text' as const, text: '看这张图' }, @@ -462,26 +422,21 @@ describe('prompt and cancel errors', () => { const prompted = await session.prompt(content, 'queue') expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagents.prompt')).toEqual([ + expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - parentSessionId: PARENT, childSessionId: SID, - mode: 'continuable', + ...CHILD, delivery: 'queue', content, - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }, ]) expect(session.getSnapshot().promptError).toBeNull() }) - it('lands an interrupt business failure in promptError with op=stop', async () => { - const api = new FakeApiClient() - api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + it('lands an interrupt business failure in promptError with op=stop', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + mock.remote.subagents.interruptByParent.mockResolvedValue(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) await session.open() const cancelled = await session.cancel() expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } }) @@ -490,12 +445,8 @@ describe('prompt and cancel errors', () => { }) }) - it('rejects staged files instead of dropping them from subagent continuations', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + it('rejects staged files instead of dropping them from subagent continuations', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() const prompted = await session.prompt([ @@ -510,17 +461,14 @@ describe('prompt and cancel errors', () => { details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, }, }) - expect(api.callsOf('subagents.prompt')).toEqual([]) + expect(mock.log.requests('subagents/prompt')).toEqual([]) }) - it('sends a one-shot address to the Host under the continuable marker', async () => { - const api = new FakeApiClient() - api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( + it('sends a one-shot address to the Host under the continuable marker', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: { ...CHILD, mode: 'one-shot' } }) + mock.remote.subagents.prompt.mockResolvedValue(err(new RemoteError( 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID }, ))) - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, - }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const cancelled = await session.cancel() @@ -528,30 +476,17 @@ describe('prompt and cancel errors', () => { // The Host reads the durable descriptor; the wire marker stays 'continuable'. expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagents.prompt')).toMatchObject([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) + expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.requests(FOLLOW)).toEqual([ + { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 50 }, ]) - expect(api.callsOf('subagents.interruptByParent')).toEqual([ - { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, - ]) - expect(api.callsOf('session.follow')).toEqual([ - { - address: { - kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', - }, - assistantStream: true, - maxMessages: 50, - }, - ]) - expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('session.cancel')).toEqual([]) + expect(mock.log.requests(PAGE)).toEqual([]) + expect(mock.log.requests('session/cancel')).toEqual([]) }) - it('delivers an image continuation to the Host without narrowing its upload parts', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - }) + it('delivers an image continuation to the Host without narrowing its upload parts', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt( [{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }], @@ -559,13 +494,13 @@ describe('prompt and cancel errors', () => { ) expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagents.prompt')).toMatchObject([ + expect(mock.log.requests('subagents/prompt')).toMatchObject([ { content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] }, ]) }) - it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => { - const { api, session } = makeSession() + it('publishes the first-prompt lifecycle synchronously before the Remote settles', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleBlank(true) expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, awaitingFirstTurn: false, @@ -579,20 +514,20 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: true, }) - expect(api.callsOf('session.prompt')).toMatchObject([{ + expect(mock.log.requests('session/prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }]) session.handleRunning(true) expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) }) - it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => { - const { api, session } = makeSession() + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleBlank(true) - api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } }) @@ -601,15 +536,18 @@ describe('prompt and cancel errors', () => { }) }) - it('propagates a non-Remote throw raised while cancelling', async () => { - const { api, session } = makeSession() - api.onCancel = () => Promise.reject(new Error('cancel transport down')) - await expect(session.cancel()).rejects.toThrow('cancel transport down') - expect(session.getSnapshot().promptError).toBeNull() + it('receives a carrier throw while cancelling as the client\'s gateway/internal fold, landing op=stop', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.cancel.mockImplementation(() => Promise.reject(new Error('cancel transport down'))) + const cancelled = await session.cancel() + expect(cancelled).toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/cancel failed: cancel transport down' }, + }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'gateway/internal' } }) }) - it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { - const { api, session } = makeSession() + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const result = await session.readAttachment('attachment-1' as never) expect(result).toEqual({ ok: true, @@ -618,19 +556,19 @@ describe('prompt and cancel errors', () => { data: Uint8Array.of(0), }, }) - expect(api.callsOf('session.attachment')).toEqual([{ + expect(mock.log.requests('session/attachment')).toEqual([{ sessionId: SID, attachmentId: 'attachment-1', }]) }) }) describe('rename', () => { - it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => { - const { api, session } = makeSession() - api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 })) + it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.rename.mockResolvedValue(ok({ title: '正名', seq: 7 })) const result = await session.rename(' 正名 ') expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } }) - expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) + expect(mock.log.requests('session/rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') // A stale lower-seq apply (the push-frame path routes into this same // store) must not roll the settled value back. @@ -638,69 +576,79 @@ describe('rename', () => { expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') }) - it('returns the business error untouched and folds a transport throw to internal', async () => { - const { api, session } = makeSession() - api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID }))) + it('returns the business error untouched and a carrier throw as the client\'s gateway/internal fold', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID }))) const rejected = await session.rename(' ') expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } }) expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() - api.onRename = () => Promise.reject(new Error('rename transport down')) - await expect(session.rename('x')).rejects.toThrow('rename transport down') + mock.remote.session.rename.mockImplementation(() => Promise.reject(new Error('rename transport down'))) + const folded = await session.rename('x') + expect(folded).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: rename transport down' } }) + expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() }) }) describe('remaining branches', () => { - it('propagates a non-Remote throw raised while prompting', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.reject(new Error('prompt wire down')) - await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down') - expect(session.getSnapshot().promptError).toBeNull() + it('receives a carrier throw while prompting as the client\'s gateway/internal fold, landing op=send', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.prompt.mockImplementation(() => Promise.reject(new Error('prompt wire down'))) + const prompted = await session.prompt([{ type: 'text', text: 'x' }], 'queue') + expect(prompted).toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/prompt failed: prompt wire down' }, + }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'gateway/internal' } }) }) - it('cancel business error also lands op=stop promptError', async () => { - const { api, session } = makeSession() - api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' }))) + it('cancel business error also lands op=stop promptError', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.cancel.mockResolvedValue(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' }))) await session.cancel() expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } }) }) - it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { - const { api, session } = makeSession() - await session.loadOlder() // cold: no-op, zero calls - expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) + it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.loadOlder() // cold: no-op, zero traffic + expect(mock.log.requests()).toEqual([]) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() // err result: window unchanged - api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) + mock.remote.session.page.mockResolvedValue(err(new RemoteError('gateway/internal', 'x', {}))) await session.loadOlder() expect(eventSeqs(session)).toHaveLength(6) expect(session.getSnapshot().hasMore).toBe(true) // empty page: hasMore adopts the response - api.onHistory = () => histResponse([], false) + mock.remote.session.page.mockImplementation(pageRule(history([]))) await session.loadOlder() expect(session.getSnapshot().hasMore).toBe(false) // hasMore false now: further loadOlder is a guard no-op - const calls = api.calls.length + const sent = mock.log.requests().length await session.loadOlder() - expect(api.calls.length).toBe(calls) - // throw path: fail-soft with console.error + expect(mock.log.requests()).toHaveLength(sent) + // failure paths, after a resync reopens hasMore from the follow snapshot: a carrier throw arrives folded as a + // Remote failure and stays quiet; a local throw fails soft with console.error const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const prepend = vi.spyOn(SessionEventStream.prototype, 'prepend') try { await session.resync() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) - await session.resync() - api.onHistory = () => Promise.reject(new Error('page wire down')) + mock.remote.session.page.mockImplementation(() => Promise.reject(new Error('page wire down'))) await session.loadOlder() - expect(errorSpy).toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() + expect(session.getSnapshot().loadingOlder).toBe(false) + prepend.mockRejectedValueOnce(new Error('local fault')) + await session.loadOlder() + expect(errorSpy).toHaveBeenCalledWith('[session-controller] loadOlder failed:', expect.any(Error)) expect(session.getSnapshot().loadingOlder).toBe(false) } finally { + prepend.mockRestore() errorSpy.mockRestore() } }) - it('subscribe delivers snapshot-change notifications and unsubscribes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('subscribe delivers snapshot-change notifications and unsubscribes', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) let notified = 0 const unsubscribe = session.subscribe(() => { notified++ }) await session.open() @@ -713,16 +661,11 @@ describe('remaining branches', () => { expect(notified).toBe(seen) }) - it('rejects an opening page that does not end at the opening cursor', async () => { - const { api, session } = makeSession() - let call = 0 - api.onHistory = () => { - call++ - return histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) - } - api.followCursor = 11 + it('rejects an opening page that does not end at the opening cursor', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')), { cursor: 11 })) await session.open() - expect(call).toBe(1) + expect(mock.log.requests(FOLLOW)).toHaveLength(1) const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') expect(snapshot.openError).toMatchObject({ @@ -731,8 +674,8 @@ describe('remaining branches', () => { expect(eventSeqs(session)).toEqual([]) }) - it('deduplicates repeated running flips and records removal', () => { - const { session } = makeSession() + it('deduplicates repeated running flips and records removal', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const before = session.getSnapshot() session.handleRunning(false) // already false: dedup branch expect(session.getSnapshot()).toBe(before) @@ -740,23 +683,23 @@ describe('remaining branches', () => { expect(session.getSnapshot().removed).toBe(true) }) - it('drops live events while cold/error (no window upkeep)', async () => { - const { api, session } = makeSession() - await follow(api, ev.user(SessionSeq(0), '冷态帧')) + it('drops live events while cold/error (no window upkeep)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await pushEvent(mock, ev.user(SessionSeq(0), '冷态帧')) // no follow is open: nothing receives it expect(eventSeqs(session)).toEqual([]) - api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) + mock.stream(FOLLOW, followScript(err(new RemoteError('gateway/internal', 'x', {})))) await session.open() - await follow(api, ev.user(SessionSeq(0), '错态帧')) + await pushEvent(mock, ev.user(SessionSeq(0), '错态帧')) expect(eventSeqs(session)).toEqual([]) }) - it('preserves a Host-reported failure that terminates the live source', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('preserves a Host-reported failure that terminates the live source', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID }) - api.failStreams(failure) + mock.streams.fail(FOLLOW, failure) await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) expect(session.getSnapshot().openError).toMatchObject({ @@ -764,151 +707,120 @@ describe('remaining branches', () => { }) }) - it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('coalesces queued gap frames behind one repair and exposes a failed repair', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const gate = deferred>>() - let repairs = 0 - api.onHistory = () => { - repairs++ - return gate.promise - } - const deliveries = Promise.all([ - follow(api, ev.user(SessionSeq(9), '洞一')), - follow(api, ev.user(SessionSeq(10), '洞二')), - ]) - await vi.waitFor(() => { expect(repairs).toBe(1) }) - gate.reject(new RemoteError('gateway/internal', 'repair wire down', {})) - await deliveries + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(gate.promise)) + await pushEvent(mock, ev.user(SessionSeq(9), '洞一')) + await pushEvent(mock, ev.user(SessionSeq(10), '洞二')) + await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) }) + gate.resolve(err(new RemoteError('gateway/internal', 'repair wire down', {}))) await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' }) expect(eventSeqs(session)).toHaveLength(6) }) - it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise + it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const stale = Promise.withResolvers>() + mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) const resynced = session.resync() stale.reject(new Error('stale wire')) await Promise.all([opening, resynced]) expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error }) - it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise + it('drops a stale doOpen whose history resolved successfully after resync superseded it', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const stale = Promise.withResolvers>() + mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代')) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) const resynced = session.resync() - stale.resolve(ok({ - records: entries(plainTurn(SessionSeq(0), 0, '旧', '代')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) // success, but its generation is gone + stale.resolve(history(plainTurn(SessionSeq(0), 0, '旧', '代'))) // success, but its generation is gone await Promise.all([opening, resynced]) expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq)) }) - it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('drops a gap repair superseded by a full resync while its pull was in flight', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const repairPull = deferred>>() - api.onHistory = () => repairPull.promise - const delivery = follow(api, ev.user(SessionSeq(9), '洞')) - await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) }) - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'c', 'd')) + const repairPull = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(repairPull.promise)) + await pushEvent(mock, ev.user(SessionSeq(9), '洞')) + await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) }) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'c', 'd')))) const resynced = session.resync() // bumps the generation - repairPull.resolve(ok({ - records: entries(plainTurn(SessionSeq(0), 0, '旧', '页')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) // repair result: stale, dropped - await Promise.all([delivery, resynced]) + repairPull.resolve(history(plainTurn(SessionSeq(0), 0, '旧', '页'))) // repair result: stale, dropped + await resynced expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, 'c', 'd').map(event => event.seq)) }) - it('successful cancel leaves no promptError', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('successful cancel leaves no promptError', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() const result = await session.cancel() expect(result.ok).toBe(true) expect(session.getSnapshot().promptError).toBeNull() }) - it('dispose is a reserved no-op on resident instances', async () => { - const { session } = makeSession() + it('dispose is a reserved no-op on resident instances', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await expect(session.dispose()).resolves.toBeUndefined() }) - it('carries raw history and follow events through the event feed', async () => { - const { api, session } = makeSession() + it('carries raw history and follow events through the event feed', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const historyCall = ev.toolCall(SessionSeq(6), 1, 'h1', 'bash', '{"cmd":"pwd"}') const historyResult = ev.toolResult(SessionSeq(7), 1, 'h1', 'done') - api.onHistory = () => Promise.resolve(ok({ - records: [ - ...entries(plainTurn(SessionSeq(0), 0, 'a', 'b')), - { type: 'event', event: historyCall }, - { type: 'event', event: historyResult }, - ] as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) + mock.stream(FOLLOW, followScript(history([...plainTurn(SessionSeq(0), 0, 'a', 'b'), historyCall, historyResult]))) await session.open() - expect(windowEntries(session).slice(-2)).toEqual([ - { type: 'event', event: historyCall }, - { type: 'event', event: historyResult }, - ]) + expect(windowEntries(session).slice(-2)).toEqual(entries([historyCall, historyResult])) const liveCall = ev.toolCall(SessionSeq(8), 2, 'l1', 'write', '{"file_path":"a.ts"}') - await follow(api, liveCall) - expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall }) + await pushEvent(mock, liveCall) + expect(windowEntries(session).at(-1)).toEqual(frame(liveCall)) const liveResult = ev.toolResult(SessionSeq(9), 2, 'l1', 'ok') - await follow(api, liveResult) - expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult }) + await pushEvent(mock, liveResult) + expect(windowEntries(session).at(-1)).toEqual(frame(liveResult)) }) }) describe('resync', () => { - it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '旧', '窗')) + it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '旧', '窗')))) await session.open() const oldWindow = session.eventSource.getSnapshot() - const replacement = deferred>>() - api.followCursor = 15 - api.onHistory = () => replacement.promise + const replacement = Promise.withResolvers() + // The reconnect generation: its snapshot is gated, then two live frames land out of order right behind it. + mock.stream(FOLLOW, async ([request], stream) => { + stream.push(followSnapshot(await replacement.promise, request as SessionFollowRequest)) + stream.push(frame(ev.user(SessionSeq(17), '后到高位'))) + stream.push(frame(ev.user(SessionSeq(16), '后到低位'))) + }) + mock.remote.session.page.mockImplementation(pageRule(history([ + ...plainTurn(SessionSeq(10), 2, '终', '页'), + ev.user(SessionSeq(16), '后到低位'), + ev.user(SessionSeq(17), '后到高位'), + ]))) const publications: ReturnType[] = [] const off = session.eventSource.subscribe(() => { publications.push(session.eventSource.getSnapshot()) }) const syncing = session.resync() - await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) }) + await vi.waitFor(() => { expect(mock.log.requests(FOLLOW)).toHaveLength(2) }) expect(session.eventSource.getSnapshot()).toBe(oldWindow) expect(publications).toEqual([]) - api.onHistory = () => histResponse([ - ...plainTurn(SessionSeq(10), 2, '终', '页'), - ev.user(SessionSeq(16), '后到低位'), - ev.user(SessionSeq(17), '后到高位'), - ]) - const liveDeliveries = Promise.all([ - follow(api, ev.user(SessionSeq(17), '后到高位')), - follow(api, ev.user(SessionSeq(16), '后到低位')), - ]) - expect(session.eventSource.getSnapshot()).toBe(oldWindow) - replacement.resolve(ok({ - records: entries(plainTurn(SessionSeq(10), 2, '终', '页')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await Promise.all([syncing, liveDeliveries]) + replacement.resolve(historyValue(plainTurn(SessionSeq(10), 2, '终', '页'))) + await syncing await vi.waitFor(() => { expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) }) @@ -920,13 +832,13 @@ describe('resync', () => { off() }) - it('rebuilds the window without clearing control state; cold instances no-op', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('rebuilds the window without clearing control state', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() session.handleRunning(true) session.handleAgentError('still visible') - api.onHistory = () => histResponse([...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')]) + mock.stream(FOLLOW, followScript(history([...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')]))) await session.resync() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') @@ -934,17 +846,20 @@ describe('resync', () => { expect(snapshot.lastAgentError).toBe('still visible') expect(eventSeqs(session)).toHaveLength(12) - const cold = makeSession() - await cold.session.resync() - expect(cold.api.calls).toEqual([]) // never opened: no traffic }) - it('drops a stale in-flight open superseded by resync (generation guard)', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise + it('does not resync a cold Session', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.resync() + expect(mock.log.requests()).toEqual([]) + }) + + it('drops a stale in-flight open superseded by resync (generation guard)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const stale = Promise.withResolvers>() + mock.stream(FOLLOW, followScript(() => stale.promise)) const firstOpen = session.open() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代')) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) const resynced = session.resync() stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late await firstOpen @@ -953,18 +868,17 @@ describe('resync', () => { expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq)) }) - }) describe('snapshot ownership', () => { - it('publishes event-window appends without changing an unrelated Session snapshot', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '稳', '定')) + it('publishes event-window appends without changing an unrelated Session snapshot', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '稳', '定')))) await session.open() const sessionBefore = session.getSnapshot() const windowBefore = session.eventSource.getSnapshot() const firstEntry = windowBefore.entries[0] - await follow(api, ev.user(SessionSeq(6), '追加')) + await pushEvent(mock, ev.user(SessionSeq(6), '追加')) const windowAfter = session.eventSource.getSnapshot() expect(session.getSnapshot()).toBe(sessionBefore) expect(windowAfter).not.toBe(windowBefore) diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 5bf6be8d5a..815722d3df 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -83,7 +83,9 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts new file mode 100644 index 0000000000..f18cec224d --- /dev/null +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -0,0 +1,100 @@ +/** + * The Remote side of one Workspace registry under test: default answers for + * every `workspace/*` command a `ClientWorkspaceModel` calls, builders for the + * rows and frames of the `workspace/follow` stream, and a script that hands + * each physical generation of that stream to its own script. The stream's + * boot-time opening baseline lives in `remoteDefaultResponses`. + */ +import { ok, type RemoteTable, type StreamScript } from '@deepseek-ai/dsh-remote-mock' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceId, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, + WorkspaceView, +} from '../../src/types.ts' + +/** The Workspace state stream endpoint. */ +export const FOLLOW = 'workspace/follow' + +/** The opening frame of one follow generation. */ +export type WorkspaceBaselineFrame = Extract + +/** + * The failure branch of a Remote result. + * @param error - the owner-declared failure. + * @returns the result. + */ +export function err(error: RemoteFailure): RemoteResult { + return { ok: false, error } +} + +/** + * One Workspace row whose id doubles as its title and path segment. + * @param id - Workspace id. + * @param overrides - fields replacing the derived ones. + * @returns the row. + */ +export function workspace(id: string, overrides: Partial = {}): WorkspaceView { + return { + workspaceId: id as WorkspaceId, + path: `/work/${id}`, + title: id, + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +/** + * A baseline frame holding the named Workspaces and no archived Sessions. + * @param ids - Workspace ids in registry order. + * @returns the frame. + */ +export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame { + return { type: 'baseline', value: { items: ids.map(id => workspace(id)), archivedSessionIds: [] } } +} + +/** + * `workspace/follow` script running the n-th open on the n-th script; an open + * past the last script fails the stream. + * @param generations - one script per physical generation, in open order. + * @returns the script. + */ +export function followGenerations(generations: readonly StreamScript[]): StreamScript { + let opened = 0 + return (args, stream) => { + const generation = generations[opened++] + if (generation === undefined) throw new Error('no scripted Workspace follow generation') + return generation(args, stream) + } +} + +/** Default answers: every command accepted and echoed back as the row or set it names. */ +export const workspaceWorld: RemoteTable = { + unary: { + 'workspace/create': (request: WorkspaceCreateRequest): RemoteResult => ok({ + workspace: workspace('created', { path: request.path }), created: true, + }), + 'workspace/rename': (request: WorkspaceRenameRequest): RemoteResult => ok({ + workspace: workspace(String(request.workspaceId), { title: request.title }), + }), + 'workspace/delete': (_request: WorkspaceDeleteRequest): RemoteResult => ok({ deleted: true }), + 'workspace/insertBefore': (request: WorkspaceInsertBeforeRequest): RemoteResult => ok({ workspaceIds: [request.workspaceId] }), + 'workspace/insertSessionBefore': (request: WorkspaceInsertSessionBeforeRequest): RemoteResult => ok({ + workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }), + }), + 'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult => ok({ archivedSessionIds: [request.sessionId] }), + }, +} diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b050b7c22c..3ee7462d03 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -1,101 +1,53 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import { - RemoteStream, - RemoteStreamCarrierError, - type ClientRemote, - type RemoteStreamOptions, -} from '@deepseek-ai/dsh-api-gateway/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +/** + * Workspace Controller client plugin, state stream, and command facade driven + * through the assembled Gateway client: every `workspace/*` call crosses the + * roster's own Connection and is answered by endpoint name. + */ + +import { describe, expect, onTestFinished, vi } from 'vitest' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import { SessionId } from '@deepseek-ai/dsh-session/types' -import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import * as WorkspaceClientPlugin from '../src/client/index.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { frames, openStream, type RemoteMock, type StreamScript } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ClientWorkspaceModel, createWorkspaceStateStream, WorkspaceController, WorkspaceCreateError, type WorkspaceFollowSink, - type WorkspaceRemote, } from '../src/client/index.ts' -import type { - WorkspaceArchiveSessionRequest, - WorkspaceArchiveValue, - WorkspaceCreateRequest, - WorkspaceCreateValue, - WorkspaceDeleteRequest, - WorkspaceDeleteValue, - WorkspaceFollowFrame, - WorkspaceInsertBeforeRequest, - WorkspaceInsertSessionBeforeRequest, - WorkspaceOrderValue, - WorkspaceRenameRequest, - WorkspaceId, - WorkspaceValue, - WorkspaceView, -} from '../src/types.ts' +import type { WorkspaceFollowFrame, WorkspaceId } from '../src/types.ts' +import { FOLLOW, baseline, err, followGenerations, workspace, workspaceWorld } from './remote/workspace.client.ts' -const AVAILABLE_CONNECTION = { - generation: { - getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), - subscribe: () => () => {}, - }, -} - -function workspaceClient( - remote: WorkspaceRemote, - connection: Pick = AVAILABLE_CONNECTION, -): ClientRemote { - return { - workspace: remote, - $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), - } as unknown as ClientRemote -} - -interface Generation { - readonly frames: readonly WorkspaceFollowFrame[] - readonly error?: unknown - readonly hold?: boolean - readonly afterAbort?: () => void - readonly afterAbortError?: unknown -} - -const baseline = (id?: string): Extract => ({ - type: 'baseline', - value: { - items: id === undefined ? [] : [{ - workspaceId: id as never, - path: `/work/${id}`, - title: id, - sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }], - archivedSessionIds: [], - }, -}) +const SELF = '@deepseek-ai/dsh-api-workspace-controller' +/** The plugin as the web bundle composes it: itself plus the Gateway client, the Connection, and the Typert registry. */ +const PLUGIN_ROSTER = webApp.closure([SELF]) +/** A stream or model built by hand talks through the Gateway client alone. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const pluginTest = createClientTest({ roster: PLUGIN_ROSTER }) +const it = createClientTest({ roster: API_ROSTER }) +/** The first client boot pays the cold module transform of the plugin cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 const wid = (id: string): WorkspaceId => id as WorkspaceId const sid = (id: string): SessionId => SessionId(id) -function workspace(id: string, overrides: Partial = {}): WorkspaceView { - return { - workspaceId: wid(id), - path: `/work/${id}`, - title: id, - sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - ...overrides, - } +/** Boot the plugin with `follow` answering its state stream. */ +async function pluginClient(mock: RemoteMock, start: () => Promise, follow: StreamScript): Promise { + mock.stream(FOLLOW, follow) + return start() } -function remoteOk(value: T): RemoteResult { - return { ok: true, value } +/** Boot the Gateway client with the Workspace command answers registered, so `remote.workspace` is provided. */ +async function gatewayClient(mock: RemoteMock, start: () => Promise): Promise<{ remote: ClientRemote; client: TestClient }> { + mock.load(workspaceWorld) + const client = await start() + return { remote: client.ctx.remote, client } } -function remoteFailure(error: RemoteFailure): RemoteResult { - return { ok: false, error } +const carrierLoss = (message: string): StreamScript => (_args, stream) => { + stream.fail(new RemoteStreamCarrierError(message)) } function accepts(overrides: Partial = {}): WorkspaceFollowSink { @@ -110,166 +62,75 @@ function accepts(overrides: Partial = {}): WorkspaceFollowS } } -class ScriptedWorkspaceRemote implements WorkspaceRemote { - readonly signals: AbortSignal[] = [] - calls = 0 - - constructor(private readonly generations: readonly Generation[]) {} - - create(_request: WorkspaceCreateRequest): Promise> { - throw new Error('unused') - } - - rename(_request: WorkspaceRenameRequest): Promise> { - throw new Error('unused') - } - - delete(_request: WorkspaceDeleteRequest): Promise> { - throw new Error('unused') - } - - insertBefore(_request: WorkspaceInsertBeforeRequest): Promise> { - throw new Error('unused') - } - - insertSessionBefore(_request: WorkspaceInsertSessionBeforeRequest): Promise> { - throw new Error('unused') - } - - archiveSession(_request: WorkspaceArchiveSessionRequest): Promise> { - throw new Error('unused') - } - - async *follow(signal = new AbortController().signal): AsyncIterable { - const generation = this.generations[this.calls++] - if (generation === undefined) throw new Error('no scripted Workspace generation') - this.signals.push(signal) - for (const frame of generation.frames) yield frame - if (generation.error !== undefined) throw generation.error - if (generation.hold === true && !signal.aborted) { - await new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - generation.afterAbort?.() - if (generation.afterAbortError !== undefined) throw generation.afterAbortError - } - } -} - -class CommandWorkspaceRemote implements WorkspaceRemote { - readonly create = vi.fn(request => Promise.resolve(remoteOk({ - workspace: workspace('created', { path: request.path }), - created: true, - }))) - - readonly rename = vi.fn(request => Promise.resolve(remoteOk({ - workspace: workspace(String(request.workspaceId), { title: request.title }), - }))) - - readonly delete = vi.fn(() => Promise.resolve(remoteOk({ deleted: true }))) - - readonly insertBefore = vi.fn(request => Promise.resolve(remoteOk({ - workspaceIds: [request.workspaceId], - }))) - - readonly insertSessionBefore = vi.fn(request => Promise.resolve(remoteOk({ - workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }), - }))) - - readonly archiveSession = vi.fn(request => Promise.resolve(remoteOk({ - archivedSessionIds: [request.sessionId], - }))) - - async *follow(_signal?: AbortSignal): AsyncIterable {} -} - -async function waitFor(check: () => void): Promise { - for (let attempt = 0; attempt < 40; attempt++) { - try { - check() - return - } catch { - await Promise.resolve() - } - } - check() -} - -function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { - const connection: ConnectionHandle = { - isLoopback: true, - generation: AVAILABLE_CONNECTION.generation, - state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - reconnect: () => {}, - registerGenerationSource: () => () => {}, - start: () => ({ stop: () => {} }), - } - ctx.reflect.provide('connection', connection) - ctx.reflect.provide('remote', workspaceClient(remote, connection)) - ctx.reflect.provide('remote.workspace', remote) +function streamStates(mock: RemoteMock): string[] { + return mock.log.streams(FOLLOW).map(row => row.state) } describe('Workspace Controller Client apply', () => { - it('provides the Workspace service and stops its follow generation with the plugin fiber', async () => { - const ctx = new Context() - const remote = new ScriptedWorkspaceRemote([{ frames: [baseline('mounted')], hold: true }]) - provideClientServices(ctx, remote) - const fiber = ctx.plugin(WorkspaceClientPlugin) - await fiber - await waitFor(() => { - expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + pluginTest('provides the Workspace service and stops its follow generation with the plugin fiber', async ({ mock, start }) => { + const client = await pluginClient(mock, start, openStream([baseline('mounted')])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', items: [{ workspaceId: 'mounted' }], }) }) - await fiber.dispose() + await client.unload(SELF) - expect(remote.signals[0]?.aborted).toBe(true) - expect(ctx.get('workspaces')).toBeUndefined() + expect(streamStates(client.mock)).toEqual(['cancelled']) + expect(client.ctx.get('workspaces')).toBeUndefined() + }, COLD_BOOT_TIMEOUT_MS) + + pluginTest('reopens the follow and re-provides the service across a Loader rebuild', async ({ mock, start }) => { + const client = await pluginClient(mock, start, openStream([baseline('mounted')])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', items: [{ workspaceId: 'mounted' }] }) + }) + const before = client.ctx.workspaces + + await client.reload(SELF) + + expect(streamStates(client.mock)).toEqual(['cancelled', 'open']) + expect(client.ctx.workspaces).not.toBe(before) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', items: [{ workspaceId: 'mounted' }] }) + }) }) - it('publishes exhausted carrier retries as a gateway/internal error state', async () => { - const ctx = new Context() + pluginTest('publishes exhausted carrier retries as a gateway/internal error state', async ({ mock, start }) => { // Neither generation reaches an accepted baseline, so the retry budget runs // out and the escaping carrier failure crosses the stream boundary marked. - const remote = new ScriptedWorkspaceRemote([ - { frames: [], error: new RemoteStreamCarrierError('generation lost') }, - { frames: [], error: new RemoteStreamCarrierError('generation lost again') }, - ]) - provideClientServices(ctx, remote) - const fiber = ctx.plugin(WorkspaceClientPlugin) - await fiber - await waitFor(() => { - expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + const client = await pluginClient(mock, start, followGenerations([ + carrierLoss('generation lost'), + carrierLoss('generation lost again'), + ])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal', message: 'generation lost again' }, }) }) - expect(remote.calls).toBe(2) - await fiber.dispose() + expect(streamStates(client.mock)).toEqual(['failed', 'failed']) }) - it('marks carrier loss while retrying and publishes a later protocol failure', async () => { - const ctx = new Context() - const remote = new ScriptedWorkspaceRemote([ - { - frames: [baseline('old')], - error: new RemoteStreamCarrierError('generation lost'), - }, - { frames: [baseline('fresh'), baseline('duplicate')] }, - ]) - provideClientServices(ctx, remote) + pluginTest('marks carrier loss while retrying and publishes a later protocol failure', async ({ mock, start }) => { const carrierFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleCarrierFailure') const streamFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleStreamFailure') - const fiber = ctx.plugin(WorkspaceClientPlugin) - await fiber - await waitFor(() => { - expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + onTestFinished(() => { + carrierFailure.mockRestore() + streamFailure.mockRestore() + }) + const client = await pluginClient(mock, start, followGenerations([ + (_args, stream) => { + stream.push(baseline('old')) + stream.fail(new RemoteStreamCarrierError('generation lost')) + }, + openStream([baseline('fresh'), baseline('duplicate')]), + ])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', items: [{ workspaceId: 'fresh' }], @@ -279,38 +140,28 @@ describe('Workspace Controller Client apply', () => { expect(carrierFailure).toHaveBeenCalledOnce() expect(streamFailure).toHaveBeenCalledOnce() - await fiber.dispose() }) }) describe('Workspace state stream', () => { - it('delivers one baseline followed by increments', async () => { + it('delivers one baseline followed by increments', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) const opening = baseline('one') - const workspace = opening.value.items[0]! - const remote = new ScriptedWorkspaceRemote([{ - frames: [ - opening, - { type: 'upsert', workspace }, - { type: 'remove', workspaceId: workspace.workspaceId }, - { type: 'order', workspaceIds: [workspace.workspaceId] }, - { type: 'archived', archivedSessionIds: ['session-one' as never] }, - ], - hold: true, - }]) + const view = opening.value.items[0]! + const increments: WorkspaceFollowFrame[] = [ + { type: 'upsert', workspace: view }, + { type: 'remove', workspaceId: view.workspaceId }, + { type: 'order', workspaceIds: [view.workspaceId] }, + { type: 'archived', archivedSessionIds: [sid('session-one')] }, + ] + mock.stream(FOLLOW, openStream([opening, ...increments])) const replaceBaseline = vi.fn() const upsertView = vi.fn() const removeView = vi.fn() const replaceOrder = vi.fn() const replaceArchived = vi.fn() - const accept = accepts({ - replaceBaseline, - upsertView, - removeView, - replaceOrder, - replaceArchived, - }) - const stream = createWorkspaceStateStream(workspaceClient(remote), { - accept, + const stream = createWorkspaceStateStream(remote, { + accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived }), failed: vi.fn(), }) @@ -319,24 +170,28 @@ describe('Workspace state stream', () => { await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() }) expect(replaceBaseline).toHaveBeenCalledWith(opening.value) - expect(upsertView).toHaveBeenCalledWith(workspace) - expect(removeView).toHaveBeenCalledWith(workspace.workspaceId) - expect(replaceOrder).toHaveBeenCalledWith([workspace.workspaceId]) + expect(upsertView).toHaveBeenCalledWith(view) + expect(removeView).toHaveBeenCalledWith(view.workspaceId) + expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId]) expect(replaceArchived).toHaveBeenCalledWith(['session-one']) await stream.dispose() - expect(remote.signals[0]?.aborted).toBe(true) + expect(streamStates(mock)).toEqual(['cancelled']) }) - it('retains the old state across carrier loss and applies the replacement baseline', async () => { + it('retains the old state across carrier loss and applies the replacement baseline', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) const carrier = new RemoteStreamCarrierError('socket lost') - const remote = new ScriptedWorkspaceRemote([ - { frames: [baseline('old')], error: carrier }, - { frames: [baseline('fresh')], hold: true }, - ]) + mock.stream(FOLLOW, followGenerations([ + (_args, stream) => { + stream.push(baseline('old')) + stream.fail(carrier) + }, + openStream([baseline('fresh')]), + ])) const replaceBaseline = vi.fn() const carrierFailed = vi.fn() const failed = vi.fn() - const stream = createWorkspaceStateStream(workspaceClient(remote), { + const stream = createWorkspaceStateStream(remote, { accept: accepts({ replaceBaseline }), carrierFailed, failed, @@ -351,14 +206,15 @@ describe('Workspace state stream', () => { await stream.dispose() }) - it('classifies a normal end after the opening baseline as carrier loss', async () => { - const remote = new ScriptedWorkspaceRemote([ - { frames: [baseline('old')] }, - { frames: [baseline('fresh')], hold: true }, - ]) + it('classifies a normal end after the opening baseline as carrier loss', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, followGenerations([ + frames([baseline('old')]), + openStream([baseline('fresh')]), + ])) const replaceBaseline = vi.fn() const carrierFailed = vi.fn() - const stream = createWorkspaceStateStream(workspaceClient(remote), { + const stream = createWorkspaceStateStream(remote, { accept: accepts({ replaceBaseline }), carrierFailed, failed: vi.fn(), @@ -372,21 +228,20 @@ describe('Workspace state stream', () => { await stream.dispose() }) - it('suppresses callback failure after disposal begins', async () => { + it('suppresses callback failure after disposal begins', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, frames([baseline()])) const failed = vi.fn() let closing: Promise | undefined - const stream = createWorkspaceStateStream( - workspaceClient(new ScriptedWorkspaceRemote([{ frames: [baseline()] }])), - { - accept: accepts({ - replaceBaseline: () => { - closing = stream.dispose() - throw new Error('disposed callback') - }, - }), - failed, - }, - ) + const stream = createWorkspaceStateStream(remote, { + accept: accepts({ + replaceBaseline: () => { + closing = stream.dispose() + throw new Error('disposed callback') + }, + }), + failed, + }) stream.start() await vi.waitFor(() => { expect(closing).toBeDefined() }) @@ -394,28 +249,27 @@ describe('Workspace state stream', () => { expect(failed).not.toHaveBeenCalled() }) - it.each([ + it.for([ { name: 'an increment before the baseline', - frames: [{ type: 'remove', workspaceId: 'one' as never }] as WorkspaceFollowFrame[], + items: [{ type: 'remove', workspaceId: wid('one') }] as WorkspaceFollowFrame[], message: 'update before its opening snapshot', }, { name: 'a duplicate baseline', - frames: [baseline(), baseline()] as WorkspaceFollowFrame[], + items: [baseline(), baseline()] as WorkspaceFollowFrame[], message: 'more than one opening snapshot', }, { name: 'a normal end before the baseline', - frames: [] as WorkspaceFollowFrame[], + items: [] as WorkspaceFollowFrame[], message: 'ended before its opening snapshot', }, - ])('reports $name as a terminal failure', async ({ frames, message }) => { + ])('reports $name as a terminal failure', async ({ items, message }, { mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, frames(items)) const failed = vi.fn() - const stream = createWorkspaceStateStream( - workspaceClient(new ScriptedWorkspaceRemote([{ frames }])), - { accept: accepts(), failed }, - ) + const stream = createWorkspaceStateStream(remote, { accept: accepts(), failed }) stream.start() await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) @@ -423,17 +277,20 @@ describe('Workspace state stream', () => { expect(failure).toBeInstanceOf(Error) if (!(failure instanceof Error)) throw new Error('expected Workspace stream failure') expect(failure.message).toContain(message) + // A protocol failure is terminal: no retry opens a second generation. + expect(mock.log.requests(FOLLOW)).toHaveLength(1) await stream.dispose() }) - it('restarts a live generation without reporting cancellation as failure', async () => { - const remote = new ScriptedWorkspaceRemote([ - { frames: [baseline('first')], hold: true }, - { frames: [baseline('second')], hold: true }, - ]) + it('restarts a live generation without reporting cancellation as failure', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, followGenerations([ + openStream([baseline('first')]), + openStream([baseline('second')]), + ])) const replaceBaseline = vi.fn() const failed = vi.fn() - const stream = createWorkspaceStateStream(workspaceClient(remote), { + const stream = createWorkspaceStateStream(remote, { accept: accepts({ replaceBaseline }), failed, }) @@ -443,18 +300,20 @@ describe('Workspace state stream', () => { stream.restart() await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) expect(failed).not.toHaveBeenCalled() + expect(streamStates(mock)).toEqual(['cancelled', 'open']) await stream.dispose() }) }) describe('WorkspaceController', () => { - it('publishes the model source and exposes successful Workspace commands', async () => { - const remote = new CommandWorkspaceRemote() - const model = new ClientWorkspaceModel(remote) + it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const model = new ClientWorkspaceModel(remote.workspace) model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] }) - const controller = new WorkspaceController(new Context(), model) + const controller = new WorkspaceController(client.ctx, model) expect(controller.list).toBe(model) + expect(client.ctx.workspaces.list).toBe(model) await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() @@ -463,32 +322,52 @@ describe('WorkspaceController', () => { }) await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() + // Each command crosses the wire as one positional request object. + expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) + expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }]) + expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }]) + expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }]) + expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }]) + expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }]) }) - it('maps generated business failures to the command facade errors', async () => { - const remote = new CommandWorkspaceRemote() - const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote)) + it('maps generated business failures to the command facade errors', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const controller = new WorkspaceController(client.ctx, new ClientWorkspaceModel(remote.workspace)) const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) - remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) + mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) - await expect(create).rejects.toThrow('workspace/invalid-path: missing path') + await expect(create).rejects.toThrow('workspace create failed: workspace/invalid-path: missing path') - remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + mock.remote.workspace.rename.mockResolvedValueOnce(err(missingWorkspace)) await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone') - remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + mock.remote.workspace.delete.mockResolvedValueOnce(err(missingWorkspace)) await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone') - remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + mock.remote.workspace.insertBefore.mockResolvedValueOnce(err(missingWorkspace)) await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone') - remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession)) + mock.remote.workspace.archiveSession.mockResolvedValueOnce(err(missingSession)) await expect(controller.archiveSession(sid('session'))) .rejects.toThrow('workspace session archive failed: session/not-found: missing session') - remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError( + mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError( 'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') }, ))) await expect(controller.insertSessionBefore(wid('missing'), sid('session'))) .rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move') }) + + it('receives a carrier throw as the client\'s gateway/internal fold, never as a rejection', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const controller = new WorkspaceController(client.ctx, new ClientWorkspaceModel(remote.workspace)) + + mock.remote.workspace.create.mockImplementation(() => Promise.reject(new Error('create wire down'))) + const create = controller.create({ path: '/work/created' }) + await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) + await expect(create).rejects.toThrow( + 'workspace create failed: gateway/internal: client api: workspace/create failed: create wire down', + ) + expect(mock.log.calls('workspace/create').map(call => call.state)).toEqual(['failed']) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed003616c8..5a96ac6370 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1020,6 +1020,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -1044,6 +1047,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1157,9 +1163,15 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../../host/directory-picker + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 4ffc774e5ce60f0e32b148338431f356454a7c03 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:22:30 +0800 Subject: [PATCH 18/19] test(settings): exercise settings through the whole-client fixture --- .../client/ui-settings-general/package.json | 13 +- .../tests/apply.client.spec.ts | 347 +++++++++--------- .../tests/shell.client.spec.ts | 214 +++++------ pnpm-lock.yaml | 3 + 4 files changed, 274 insertions(+), 303 deletions(-) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a4932dd534..3e9641549a 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -49,22 +49,23 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "clsx": "^2.0.0" + "@types/react": "~18.3.1", + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index e2e3099489..10380d26d9 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -1,20 +1,30 @@ -/** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment jsdom +/** + * Ownerless-copy registrations inside the assembled web client: the five + * seats, the `settings` dictionaries, the locale-following nav label, the + * loopback-only document action over the real settings mirror, and recovery + * across Loader rebuilds of the declaring chain. + */ +import { describe, expect, onTestFinished, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts' +import { inject } from '../src/client/index.ts' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' -// These specs assert the shipped Chinese copy. The lane has no jsdom `window`, -// so browser-language detection never runs and a fresh LocaleRuntime opens on -// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. +const SELF = '@deepseek-ai/dsh-client-ui-settings-general' +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const it = createClientTest({ roster: webApp }) +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 +/** Dictionary namespace this plugin owns; every seat it fills declares it. */ +const NS = 'settings' /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ @@ -25,56 +35,55 @@ const SEATS = [ ['settings.section', GeneralSection], ] as const -async function bench(isLoopback = true) { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - const locale = new LocaleRuntime(ctx) - locale.setLocale('zh') - ctx.provide('locale', locale) - const settingsDescribe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: true, - namespaces: [], - }, - })) - const settingsOpenDocument = vi.fn(() => Promise.resolve({ - ok: true as const, value: { opened: true as const }, - })) - const remote = new TestRemote(ctx, { - settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, - }) - // The fixed Host facts the shell reads its loopback-only action from. - remote.$host = { home: undefined, isLoopback } - ctx.provide('connection', { - state: { getSnapshot: () => 'connected', subscribe: () => () => {} }, - reconnect: () => {}, - } as never) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } +/** One Host view of the locale preference, including its revision fence. */ +function localeView(preference: string, revision = 0): SettingsNamespaceView { + return { + ns: LOCALE_SETTINGS_NAMESPACE, + // The Remote wire serializes nested Schema values before the client rehydrates them. + schema: JSON.parse(JSON.stringify(LocaleSettingsSchema.toJSON())) as SettingsNamespaceView['schema'], + value: { preference }, + applies: 'live', + secrets: [], + revision, + } } -/** Declare the shell's six child slots the way ui-settings' entry does. */ -function declare(slots: SlotRegistry): () => void { - return slots.register( - { - name: 'root', - children: { - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - } as never, - () => null, - ) +async function client(mock: RemoteMock, start: () => Promise, hasDocument = false) { + const settings = mock.remote.settings + settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')] })) + const c = await start() + // The locale adopts the Host preference once the describe mirror holds the document. + await c.ctx.settingsScope.describe().ensure() + return { c, settings } } -function generalEntry(slots: SlotRegistry) { - return slots.entries('settings.section').find(e => e.component === GeneralSection) +/** This plugin's rows in a seat: the list seats also carry feature-owned rows (the product's other sections and actions). */ +function ownEntries(c: TestClient, name: (typeof SEATS)[number][0]) { + return c.ctx.slots.entries(name).filter(entry => entry.locale === NS) +} + +function generalEntry(c: TestClient) { + return ownEntries(c, 'settings.section').find(entry => entry.component === GeneralSection)! +} + +function generalLabel(c: TestClient): string | undefined { + return resolveSlotLabel(generalEntry(c).options.label) +} + +function actionInjectedOf(c: TestClient): SettingsDocumentActionInjected { + const entry = ownEntries(c, 'settings.action')[0]! + return (entry.inject as unknown as () => SettingsDocumentActionInjected)() +} + +function expectSeated(c: TestClient): void { + for (const [name, component] of SEATS) { + expect(ownEntries(c, name).map(entry => entry.component)).toEqual([component]) + } +} + +/** The page authority the `connection` plugin classifies at apply, reconfigured through the jsdom instance vitest exposes. */ +function setPageUrl(url: string): void { + (globalThis as unknown as { jsdom: { reconfigure(settings: { url: string }): void } }).jsdom.reconfigure({ url }) } describe('ui-settings-general apply', () => { @@ -82,138 +91,144 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) - it('fills all five seats for declarations before or after apply', async () => { - const before = await bench() - declare(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - for (const [name, component] of SEATS) { - expect(before.slots.entries(name)[0]!.component).toBe(component) - } - const entry = generalEntry(before.slots)! + it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => { + const { c } = await client(mock, start) + expect(c.ctx.locale.getSnapshot().active).toBe('zh') + expectSeated(c) + const entry = generalEntry(c) expect(entry.options).toMatchObject({ id: 'general', order: 0 }) // The nav label is a locale-following thunk; owners resolve at read time. - expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') - expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - expect(before.slots.entries('settings.general.item')).toEqual([]) - // The onboarding hole stays declared for feature-owned steps; this plugin - // no longer seats one. - expect(before.slots.entries('settings.onboarding')).toEqual([]) - const action = before.slots.entries('settings.action')[0]! - const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() - expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') - expect(actionInjected.hooks.snapshot).toBe(actionInjected.controller.store) - // Copy rides the standard locale seat: every seat declares the namespace. - for (const [name] of SEATS) { - expect(before.slots.entries(name)[0]!.locale).toBe('settings') - } - const after = await bench() - await after.ctx.plugin({ inject: [...inject], apply }).await() - for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) - declare(after.slots) - await Promise.resolve() + expect(generalLabel(c)).toBe('通用设置') + expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The General items and the onboarding steps are feature-owned rows; this plugin seats none of its own. + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) + expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([]) + const { controller, hooks } = actionInjectedOf(c) + expect(controller.store.getSnapshot().status).toBe('idle') + expect(hooks.snapshot).toBe(controller.store) + // Copy rides the standard locale seat: every row this plugin seats declares the namespace. for (const [name, component] of SEATS) { - expect(after.slots.entries(name)[0]!.component).toBe(component) - // The self-inflicted ledger notifications hit the duplicate guard. - expect(after.slots.entries(name)).toHaveLength(1) + expect(c.ctx.slots.entries(name).find(row => row.component === component)!.locale).toBe(NS) } + }, COLD_BOOT_TIMEOUT_MS) + + it('registers the zh/en settings dictionaries and frees the seats when its row unloads', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const english = localeView('en', 1) + settings.mutate.mockResolvedValueOnce(ok(english)) + const t = c.ctx.locale.bind(NS) + expect(t('title')).toBe('设置') + expect(t('connection.error')).toBe('连接异常') + expect(t('connection.connecting')).toBe('自动重连中') + expect(t('connection.connected')).toBe('连接成功') + c.ctx.locale.setLocale('en') + expect(t('close')).toBe('Close') + expect(t('connection.reconnect')).toBe('Disconnected, reconnect now') + expect(t('connection.connecting')).toBe('Reconnecting') await vi.waitFor(() => { - expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english]) }) - }) - - it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.locale.bind('settings')('title')).toBe('设置') - expect(b.locale.bind('settings')('connection.error')).toBe('连接异常') - expect(b.locale.bind('settings')('connection.connecting')).toBe('自动重连中') - expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功') - b.locale.setLocale('en') - expect(b.locale.bind('settings')('close')).toBe('Close') - expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now') - expect(b.locale.bind('settings')('connection.connecting')).toBe('Reconnecting') - b.locale.setLocale('zh') - await fiber.dispose() + await c.unload(SELF) + await c.flush() // The (ns, locale) seats are free again — the dictionary disposer ran. - expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() - expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + expect(() => { c.ctx.locale.register(NS, 'zh', {})() }).not.toThrow() + expect(() => { c.ctx.locale.register(NS, 'en', {})() }).not.toThrow() }) - it('the nav label thunk follows the active locale without re-registration', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name)) - b.locale.setLocale('en') + it('the nav label thunk follows the active locale without re-registration', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const english = localeView('en', 1) + const chinese = localeView('zh', 2) + settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese)) + const zhVersions = SEATS.map(([name]) => c.ctx.slots.getVersion(name)) + c.ctx.locale.setLocale('en') // No ledger churn: freshness rides the thunk (and the renderer's locale // subscription), not re-registration. SEATS.forEach(([name], i) => { - expect(b.slots.getVersion(name)).toBe(zhVersions[i]!) - expect(b.slots.entries(name)).toHaveLength(1) + expect(c.ctx.slots.getVersion(name)).toBe(zhVersions[i]!) + expect(ownEntries(c, name)).toHaveLength(1) + }) + expect(generalLabel(c)).toBe('General') + await vi.waitFor(() => { + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english]) + }) + c.ctx.locale.setLocale('zh') + expect(generalLabel(c)).toBe('通用设置') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese]) }) - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General') - b.locale.setLocale('zh') - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置') }) - it('reads availability from the shared mirror and follows its reconnect refresh', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const entry = b.slots.entries('settings.action')[0]! - const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)() - // The mirror read once at its own boot; the action's load adds no read. - await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() }) + it('reads availability from the shared mirror and follows its reconnect refresh', async ({ mock, start }) => { + const { c } = await client(mock, start, true) + const { controller } = actionInjectedOf(c) + // Boot reads the document twice: the mirror's own `ensure` at apply, then + // the `connection/reset` of the first connection. The action's load adds none. + expect(c.mock.log.calls('settings/describe')).toHaveLength(2) await controller.load() - expect(b.settingsDescribe).toHaveBeenCalledOnce() + expect(c.mock.log.calls('settings/describe')).toHaveLength(2) expect(controller.store.getSnapshot().status).toBe('ready') - b.ctx.emit('connection/reset') - await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) + c.connection.reconnect() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { expect(c.mock.log.calls('settings/describe')).toHaveLength(3) }) }) - it('withholds the Host document action off-loopback', async () => { - const b = await bench(false) - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.action')).toEqual([]) - expect(b.settingsDescribe).not.toHaveBeenCalled() - await fiber.dispose() - for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([]) + it('withholds the Host document action off-loopback', async ({ mock, start }) => { + const loopbackUrl = location.href + setPageUrl('http://198.51.100.7:3000/') + onTestFinished(() => { setPageUrl(loopbackUrl) }) + const { c } = await client(mock, start) + expect(c.connection.isLoopback).toBe(false) + expect(ownEntries(c, 'settings.action')).toEqual([]) + // Off-loopback settings stay process-local: no describe read, so the browser language stands. + expect(c.mock.log.calls('settings/describe')).toEqual([]) + expect(c.ctx.locale.getSnapshot().active).toBe('en') + await c.unload(SELF) + await c.flush() + for (const [name] of SEATS) expect(ownEntries(c, name)).toEqual([]) }) - it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { - const b = await bench() - const redeclare = declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - // Declarer unload: the cascade removes every seat entry and the item - // declaration while our local disposers go stale. - redeclare() - for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) - expect(b.slots.spec('settings.general.item')).toBeUndefined() - declare(b.slots) - await Promise.resolve() - for (const [name, component] of SEATS) { - expect(b.slots.entries(name)[0]!.component).toBe(component) - } - expect(b.slots.entries('settings.general.item')).toEqual([]) - expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + it('re-registers after a Loader rebuild of the declaring chain (stale disposers must not block)', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const before = SEATS.map(([name]) => ownEntries(c, name)[0]) + await c.reload(SIDEBAR) + await c.flush() + expectSeated(c) + SEATS.forEach(([name], index) => { + expect(ownEntries(c, name)[0]).not.toBe(before[index]) + }) + expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) // The recovered registrations still ride the locale path. - b.locale.setLocale('en') - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General') - b.locale.setLocale('zh') + const english = localeView('en', 1) + const chinese = localeView('zh', 2) + settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese)) + c.ctx.locale.setLocale('en') + expect(generalLabel(c)).toBe('General') + c.ctx.locale.setLocale('zh') + expect(generalLabel(c)).toBe('通用设置') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese]) + }) }) - it('removes every seat and the item declaration on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.spec('settings.general.item')).toBeDefined() - await fiber.dispose() - for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) - expect(b.slots.spec('settings.general.item')).toBeUndefined() + it('removes every seat and the item declaration when its row unloads', async ({ mock, start }) => { + const { c } = await client(mock, start) + expect(c.ctx.slots.spec('settings.general.item')).toBeDefined() + await c.unload(SELF) + await c.flush() + for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0) + expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 8e33a75a10..24ca33b895 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -1,54 +1,25 @@ -/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject } from '../src/client/index.ts' +// @vitest-environment jsdom +/** + * Settings shell registration inside the assembled web client: the shell + * occupies the `sidebar.settings` hole ui-sidebar declares, its ledger + * projections read the product's real sections, its connection control is the + * roster's Connection, and it survives a Loader rebuild of the declarer. + */ +import { describe, expect, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - // Copy machinery the shell only reads a revision from; the real locale - // plugin would drag its own settings-row dependencies into this bench. - ctx.provide('locale', { - register: () => () => {}, - bind: () => (key: string) => key, - getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), - subscribe: () => () => {}, - } as never) - // The shell mounts ui-settings, which injects `remote.settings`; without the - // namespace provided its fiber parks and no slot is ever declared. - const settings = { - describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), - } - const reconnect = vi.fn() - const connectionState = { - getSnapshot: () => 'connected' as const, - subscribe: () => () => {}, - } - ctx.provide('connection', { state: connectionState, reconnect } as never) - ctx.provide('remote', { - $on: () => () => {}, - $host: { home: undefined, isLoopback: false }, - settings, - } as never) - ctx.provide('remote.settings', settings as never) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect } -} +const SELF = '@deepseek-ai/dsh-client-ui-settings-general' +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const it = createClientTest({ roster: webApp }) +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 -function declare(slots: SlotRegistry): () => void { - return slots.register( - { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} - -function injectedOf(slots: SlotRegistry): SettingsRootInjected { - const entry = slots.entries('sidebar.settings')[0]! +function injectedOf(c: TestClient): SettingsRootInjected { + const entry = c.ctx.slots.entries('sidebar.settings')[0]! return (entry.inject as () => SettingsRootInjected)() } @@ -61,82 +32,74 @@ const CHILD_SPECS = { 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, } as const +const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array -describe('ui-settings apply', () => { - it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', - ]) +/** + * Section ids the web-app roster registers, in nav order: this package, then + * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin adding + * a section changes this list. + */ +const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets'] +/** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */ +const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [ + { id: 'welcome-notice', order: -100 }, + { id: 'deepseek-official', order: 0 }, +] + +describe('ui-settings-general shell', () => { + it('declares its services', () => { + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) - it('registers the shell and declares every child slot, before or after the declaration', async () => { - const before = await bench() - declare(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name]) - } + it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => { + const c = await start() + expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot]) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) + }, COLD_BOOT_TIMEOUT_MS) - const after = await bench() - await after.ctx.plugin({ inject: [...inject], apply }).await() - expect(after.slots.entries('sidebar.settings')).toHaveLength(0) - declare(after.slots) - await Promise.resolve() - expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - // The self-inflicted ledger notifications hit the duplicate guard. - expect(after.slots.entries('sidebar.settings')).toHaveLength(1) - }) - - it('projects the section ledger into ordered nav rows with option defaults', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const { sections } = injectedOf(b.slots).hooks - // This package registers the General section itself; every other section - // arrives from a feature registrant. - const GENERAL = { id: 'general', order: 0, label: 'general.nav' } - expect(sections.getSnapshot()).toEqual([GENERAL]) - b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - // No order and no label: both projection defaults apply. - b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => { + const c = await start() + const { sections } = injectedOf(c).hooks + const product = sections.getSnapshot() + expect(product.map(row => row.id)).toEqual(PRODUCT_SECTIONS) + expect(product[0]).toEqual({ id: 'general', order: 0, label: expect.any(String) as string }) + c.ctx.slots.register({ name: 'settings.section', id: 'z', order: 1_000, label: 'Z' } as never, () => null) + // No order and no label: both projection defaults apply, and order 0 sorts among the product rows. + c.ctx.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) const rows = sections.getSnapshot() - expect(rows).toEqual([ - GENERAL, - { id: 'a', order: 0, label: '' }, - { id: 'z', order: 20, label: 'Z' }, - ]) + expect(rows.at(-1)).toEqual({ id: 'z', order: 1_000, label: 'Z' }) + expect(rows.find(row => row.id === 'a')).toEqual({ id: 'a', order: 0, label: '' }) + expect(rows.map(row => row.order)).toEqual([...rows.map(row => row.order)].sort((x, y) => x - y)) // Snapshot identity is stable until the ledger moves (uSES contract). expect(sections.getSnapshot()).toBe(rows) const listener = vi.fn() const off = sections.subscribe(listener) - b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + c.ctx.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalled() expect(sections.getSnapshot()).not.toBe(rows) off() }) - it('projects the Gateway connection control without copying its state', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots) - expect(injected.hooks.connectionState).toBe(b.connectionState) + it('projects the roster Connection control without copying its state; reconnect opens a new $events generation', async ({ start }) => { + const c = await start() + const injected = injectedOf(c) + expect(injected.hooks.connectionState).toBe(c.connection.state) + expect(injected.hooks.connectionState.getSnapshot()).toBe('connected') injected.reconnect() - expect(b.reconnect).toHaveBeenCalledOnce() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { expect(c.connection.state.getSnapshot()).toBe('connected') }) }) - it('projects onboarding entries into stable coordinator order', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const { onboardingSteps } = injectedOf(b.slots).hooks - b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) - b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) - b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) + it('projects onboarding entries into stable coordinator order', async ({ start }) => { + const c = await start() + const { onboardingSteps } = injectedOf(c).hooks + expect(onboardingSteps.getSnapshot()).toEqual(PRODUCT_ONBOARDING) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) const steps = onboardingSteps.getSnapshot() - expect(steps).toEqual([ + expect(steps.filter(step => !PRODUCT_ONBOARDING.some(known => known.id === step.id))).toEqual([ { id: 'welcome', order: -100 }, { id: 'credential', order: 0 }, { id: 'default-order', order: 0 }, @@ -144,39 +107,28 @@ describe('ui-settings apply', () => { expect(onboardingSteps.getSnapshot()).toBe(steps) const listener = vi.fn() const off = onboardingSteps.subscribe(listener) - b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalledOnce() off() }) - it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { - const b = await bench() - const redeclare = declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('sidebar.settings')).toHaveLength(1) - // Declarer unload: the cascade removes our entry and every child - // declaration while our local disposer variable goes stale. - redeclare() - expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - expect(b.slots.spec('settings.trigger')).toBeUndefined() - declare(b.slots) - await Promise.resolve() - expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name]) - } + it('re-registers after the declarer reloads: the cascade removes the shell, the rebuilt declaration takes it back', async ({ start }) => { + const c = await start() + const before = c.ctx.slots.entries('sidebar.settings')[0] + expect(before).toBeDefined() + await c.reload(SIDEBAR) + await c.flush() + expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot]) + expect(c.ctx.slots.entries('sidebar.settings')[0]).not.toBe(before) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) }) - it('unregisters the shell and collapses every child slot on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - await fiber.dispose() - expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(b.slots.spec(name)).toBeUndefined() - } + it('unregisters the shell and collapses every child slot when its row unloads', async ({ start }) => { + const c = await start() + await c.unload(SELF) + await c.flush() + expect(c.ctx.slots.entries('sidebar.settings')).toHaveLength(0) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toBeUndefined() }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a96ac6370..948bb100b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3628,6 +3628,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings From f74ca1e2669691349735f5fdce8ba76f9f328ec3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:32:56 +0800 Subject: [PATCH 19/19] fix(remote-mock): abort stream handle on consumer return --- .../test-support/remote-mock/README.i18n.yaml | 4 ++-- packages/test-support/remote-mock/README.md | 2 +- .../test-support/remote-mock/README.zh.md | 2 +- .../test-support/remote-mock/src/streams.ts | 22 +++++++++++-------- .../remote-mock/tests/proxy.client.spec.ts | 6 +++-- .../remote-mock/tests/streams.client.spec.ts | 15 +++++++++++++ 6 files changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml index 048e61ea36..66722a563c 100644 --- a/packages/test-support/remote-mock/README.i18n.yaml +++ b/packages/test-support/remote-mock/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/test-support/remote-mock/README.md -README.md: 48cfeec7c6a49c6bd7017db27d2feda4052858ad -README.zh.md: ca230762467f0e702622cd5dcbc24e2552679d58 +README.md: c3182b61a67aa6af799fb4fbac85083fbe805e59 +README.zh.md: 8199bc748d96df34de28d225db1289d76be6eed2 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md index 48cfeec7c6..c3182b61a6 100644 --- a/packages/test-support/remote-mock/README.md +++ b/packages/test-support/remote-mock/README.md @@ -73,7 +73,7 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the open `signal`, or an early `return`) ends the iteration without throwing and logs the stream as `cancelled`. +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. ### Connect a client diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md index ca23076246..8199bc748d 100644 --- a/packages/test-support/remote-mock/README.zh.md +++ b/packages/test-support/remote-mock/README.zh.md @@ -73,7 +73,7 @@ mock.streams.fail('session/follow', new Error('gone')) await mock.streams.drained('session/follow') ``` -失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 `signal`,或提前 `return`)结束迭代而不抛错,并把该流记为 `cancelled`。 +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。 ### 接上客户端 diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts index 359753f307..0090087720 100644 --- a/packages/test-support/remote-mock/src/streams.ts +++ b/packages/test-support/remote-mock/src/streams.ts @@ -10,7 +10,7 @@ export interface StreamHandle { end(): void /** Fail the consumer's next read with `error` after the queued items drain. */ fail(error: Error): void - /** Consumer cancellation. */ + /** Aborts when the opening signal aborts or the consumer returns early. */ readonly signal: AbortSignal } @@ -52,9 +52,11 @@ export class MockStream implements StreamHandle, AsyncIterable { private settled: Settled | undefined private waiting: { readonly resolve: (result: IteratorResult) => void; readonly reject: (error: unknown) => void } | undefined private drainWaiters: (() => void)[] = [] + private readonly cancellation = new AbortController() + readonly signal = this.cancellation.signal /** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */ - private readonly onAbort = (): void => { this.cancel() } + private readonly onAbort = (): void => { this.cancel(this.sourceSignal.reason) } /** Items pushed but not yet pulled by the consumer. */ get queued(): number { @@ -63,11 +65,11 @@ export class MockStream implements StreamHandle, AsyncIterable { /** * @param record - log entry this stream updates. - * @param signal - consumer cancellation. + * @param sourceSignal - cancellation from the caller that opened the stream. */ - constructor(readonly record: StreamRecord, readonly signal: AbortSignal) { - if (signal.aborted) this.cancel() - else signal.addEventListener('abort', this.onAbort, { once: true }) + constructor(readonly record: StreamRecord, private readonly sourceSignal: AbortSignal) { + if (sourceSignal.aborted) this.cancel(sourceSignal.reason) + else sourceSignal.addEventListener('abort', this.onAbort, { once: true }) } push(item: unknown): void { @@ -148,7 +150,7 @@ export class MockStream implements StreamHandle, AsyncIterable { if (this.record.state !== 'open') return this.record.state = state this.settled = settled - this.signal.removeEventListener('abort', this.onAbort) + this.sourceSignal.removeEventListener('abort', this.onAbort) this.wakeDrained() // A pending read exists only while the queue is empty: push() resolves it directly instead of queueing. const waiting = this.waiting @@ -157,12 +159,14 @@ export class MockStream implements StreamHandle, AsyncIterable { void this.finish(settled).then(waiting.resolve, waiting.reject) } - private cancel(): void { + private cancel(reason?: unknown): void { if (this.record.state === 'open') { this.record.state = 'cancelled' - this.signal.removeEventListener('abort', this.onAbort) + this.sourceSignal.removeEventListener('abort', this.onAbort) const waiting = this.waiting this.waiting = undefined + this.queue.length = 0 + this.cancellation.abort(reason) waiting?.resolve({ value: undefined, done: true }) } // Whether cancelled or left after the producer settled, the consumer pulls nothing more: drop what it left. diff --git a/packages/test-support/remote-mock/tests/proxy.client.spec.ts b/packages/test-support/remote-mock/tests/proxy.client.spec.ts index 38211cb6fe..ba068d70d2 100644 --- a/packages/test-support/remote-mock/tests/proxy.client.spec.ts +++ b/packages/test-support/remote-mock/tests/proxy.client.spec.ts @@ -170,7 +170,7 @@ describe('RemoteMock.remote stream proxies', () => { expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['ended', 'ended']) }) - it('shares stream spies with the carrier, preserves its args, and forwards each caller signal', async () => { + it('shares stream spies with the carrier, preserves its args, and reflects each caller cancellation', async () => { const signals: AbortSignal[] = [] const script = vi.fn((_args: readonly unknown[], stream: StreamHandle) => { signals.push(stream.signal) @@ -192,7 +192,8 @@ describe('RemoteMock.remote stream proxies', () => { await expect(wire.next()).resolves.toEqual({ value: baseline, done: false }) expect(control.mock.calls).toEqual([[localController.signal], [request, wireController.signal]]) expect(script.mock.calls.map(([args]) => args)).toEqual([[], [request]]) - expect(signals).toEqual([localController.signal, wireController.signal]) + expect(signals).toHaveLength(2) + expect(signals.map(signal => signal.aborted)).toEqual([false, false]) expect(mock.log.streams('session/control').map(entry => entry.args)).toEqual([[], [request]]) const waiting = local.next() @@ -200,6 +201,7 @@ describe('RemoteMock.remote stream proxies', () => { await expect(waiting).resolves.toEqual({ value: undefined, done: true }) await wire.return!() await mock.streams.drained('session/control') + expect(signals.map(signal => signal.aborted)).toEqual([true, true]) expect(mock.streams.push('session/control', baseline)).toBe(0) expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['cancelled', 'cancelled']) }) diff --git a/packages/test-support/remote-mock/tests/streams.client.spec.ts b/packages/test-support/remote-mock/tests/streams.client.spec.ts index b65ae8ce8a..ec41ba902f 100644 --- a/packages/test-support/remote-mock/tests/streams.client.spec.ts +++ b/packages/test-support/remote-mock/tests/streams.client.spec.ts @@ -95,6 +95,21 @@ describe('RemoteMock streams', () => { expect(mock.log.streams().map(entry => entry.state)).toEqual(['cancelled', 'cancelled', 'cancelled']) }) + it('aborts the stream handle signal when the consumer returns', async () => { + let signal: AbortSignal | undefined + const mock = RemoteMock.create().stream('s/f', (_args, stream) => { + signal = stream.signal + stream.push('first') + }) + const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + await expect(reader.next()).resolves.toEqual({ value: 'first', done: false }) + await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) + expect({ + state: mock.log.streams('s/f')[0]?.state, + signalAborted: signal?.aborted, + }).toEqual({ state: 'cancelled', signalAborted: true }) + }) + it('runs script functions with the open args and fails the stream when they throw or reject', async () => { const mock = RemoteMock.create() .stream('s/echo', (args, stream) => {