diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 9b41e2e0cf..1b529c379b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.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-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: 73768e1eee8957f8976d40812b0a31a2961f0825 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 343816abaf394b8f64924cf36b753c6b1b2e34ca +2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888 +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index 73768e1eee..737e383526 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -12,17 +12,19 @@ Occurrence-local inbox wrappers also duplicated the identity already carried by ## Decision -Before every proposed step, `Inbox.claim(target)` atomically removes the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome. The loop then emits `agent/inbox/claimed { message, turn }` once per claimed message and awaits the waterfall with that exclusive batch and `{ turn, step, signal }`. +Before every proposed step, the loop's package-internal `ReactLoopInbox` atomically claims the complete batch: all `next-step` messages and, at a turn boundary, one `next-turn` message. At the initial boundary the loop first commits `turn/start`, so the claim and its single `agent/pre-step` decision have durable turn ownership. Claiming records normalized `agent/inbox/spliced` pure deletions with no outcome, emits `agent/inbox/claimed { message, turn }` once per claimed message, and returns the exclusive batch for the loop's waterfall with `{ turn, step, signal }`. `PreStepDecision` is `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`. Reject opens no step, leaves the claimed batch removed, and closes the turn as blocked without any step events. Empty entry, cancellation, and failure before `step/start` likewise close a balanced no-step turn. Enter supplies the complete batch appended as `user/message` events after `step/start`. A listener wrapping `next()` preserves downstream changes unless it intentionally replaces them, so all message rewrites settle once in the final return value. There is no `agent/prompt-prepare`, `agent/prompt-submit`, or `agent/step` extension point. -The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming is the loop's internal step-boundary operation on the inbox and records pure deletions without notifications or an outcome, so the loop can publish claimed events itself. These live events add no placement, outcome, or batch fields. +The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Whole-queue consumers, including the Web queue projection and reconnect baseline, use the durable `agent/inbox/spliced` stream; UI edits and removals route through `Inbox.splice()` or another Inbox mutation method so the same projection records every change. +`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes. + +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. -The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` now owns addressability, while the retained Host queue mirror derives its snapshots from the durable splice projection. +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. ## Alternatives considered @@ -34,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026 ## Verification -Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, and cancellation. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, and resumed durable projection. Generated event and type catalogs expose only the new waterfall and payloads. +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index 343816abaf..ecb731df66 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -12,17 +12,19 @@ Status: implemented ## 决策 -每个拟议步骤之前,`Inbox.claim(target)` 会原子移除完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`。随后,循环针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并用该独占批次与 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 +每个拟议步骤之前,循环包内部的 `ReactLoopInbox` 会原子领取完整批次:全部 `next-step` 消息,以及轮次边界上的一条 `next-turn` 消息。在首次边界,循环会先提交 `turn/start`,使领取及其唯一一次 `agent/pre-step` 决策拥有持久轮次归属。领取会记录规范化、不带 outcome 的纯删除 `agent/inbox/spliced`,针对每条已领取消息发出一次 `agent/inbox/claimed { message, turn }`,并把独占批次返回给循环,由后者用 `{ turn, step, signal }` 等待 waterfall(瀑布式事件)。 `PreStepDecision` 为 `{ kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }`。reject 不会打开步骤,会让已领取批次保持已删除,并将轮次关闭为 blocked,且不产生任何步骤事件。空的 enter、取消以及 `step/start` 前的失败同样会关闭一个边界平衡的无步骤轮次。enter 提供在 `step/start` 后以 `user/message` 追加的完整批次。包装 `next()` 的监听器会保留下游变更,除非有意替换,因此全部消息改写只在最终返回值中一次性结算。系统不再存在 `agent/prompt-prepare`、`agent/prompt-submit` 或 `agent/step` 扩展点。 -持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取是循环在 inbox 上的内部步骤边界操作,记录不带通知或 outcome 的纯删除,因此循环可以自行发布 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 +持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。包括 Web 队列投影和重连基线在内的整体队列消费方使用持久 `agent/inbox/spliced` 流;UI 编辑与移除通过 `Inbox.splice()` 或其他 Inbox 变更方法处理,从而让同一投影记录所有变化。 +`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数。 + +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 -已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。现在由 `MessageId` 负责寻址,而保留的 Host 队列镜像根据持久 splice 投影派生快照。 +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 ## 曾考虑的替代方案 @@ -34,7 +36,7 @@ Status: implemented ## 验证 -agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败与取消。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点以及恢复后的持久投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 +agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 ## 后果 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 new file mode 100644 index 0000000000..8aa7824f1f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-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 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 new file mode 100644 index 0000000000..6d22777c89 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -0,0 +1,177 @@ +# Agent Note: Package and update the Electron desktop application + +Status: implemented + +English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) + +## 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. + +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. + +## 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`. + +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. + +The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user pnpm cannot mutate this profile. The CLI reserves every case variant of the `desktop` name and rejects boot, config-dump, and plugin-management requests for it. Electron acquires its process-lifetime single-instance lock before project recovery or Host startup; later launches focus or recreate the primary window without touching profile state. An Electron-only GUI sends structured install, remove, and update requests through preload; Electron invokes only its bundled pnpm. + +## Ownership + +| Owner | Responsibility | +|---|---| +| 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 | +| 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 | +| npm-installed dsh | Its own executable installation and user-managed profiles; no access to the reserved desktop profile or package state | + +The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`. Preload exposes typed RPC, lifecycle, update, locale, and desktop-plugin actions rather than raw `ipcRenderer`, filesystem access, shell commands, or pnpm arguments. Electron selects a typed English or Chinese dictionary from its application locale and falls back to English; menus, native dialogs, and the plugin-management renderer use that locale-owned copy. + +## Filesystem layout + +```text +~/.dsh/ + desktop/ + staging//profile/ + rollback/profile/ + pending.json + lock + pnpm/ + store/ + cache/ + state/ + config/ + profiles/ + desktop/ + package.json + pnpm-lock.yaml + pnpm-workspace.yaml + desktop-release.json + desktop-packages.json + desktop-packages/ + 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`. + +## 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 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. + +## 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. + +`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. + +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. + +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. + +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. + +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. + +## Implementation + +| Surface | Implementation | +|---|---| +| 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. | + +`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. + +## Alternatives considered + +**Use Electron's Node.js for dsh.** This saves package size but couples dsh to Electron's Node patches, fuses, native ABI, TLS behavior, and process lifecycle. A bundled upstream Node.js keeps dsh on its supported runtime. + +**Carry Fetch bodies through JSON IPC as Base64.** JSON IPC keeps one message mechanism but expands every request and response body, constructs large strings in both processes, buffers each request before dispatch, and double-encodes image bytes already represented as Base64 inside RPC JSON. Raw framed pipes retain an explicit versioned protocol without relying on Electron and upstream Node.js to share V8 serialization behavior. + +**Bake the product Web UI into Electron.** Independent UI and backend updates would require a new versioned compatibility program. Installing backend and Web UI from the same dsh package preserves the current release binding. + +**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. + +**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. + +**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. + +**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. + +**Commit a credential-bearing signing script or persist the Token Password.** A credential-bearing CMD file, `.env`, or Windows user or system environment variable leaves the Token Password recoverable at rest. The checked-in CMD contains only environment-variable references, and the packaging step accepts the password as an ephemeral runner secret. + +**Let electron-builder or a general directory sync publish directly.** A direct publisher can expose channel metadata before every referenced artifact exists, mix stale or cross-target files into a release, and cannot prove that the completed signed build still matches the current dsh version. A target-specific validated upload keeps publication ordering and release identity explicit. + +## 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. +- 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. +- 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. +- 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. +- Workspace development runs current built code without downloading release resources, while unpacked-package verification retains the production installation path. +- Windows release packaging requires the validated SignTool, EV token, matching public leaf certificate, Token Password, and explicit key container; it never falls back to an unsigned artifact or an exportable key file. +- A target update cannot expose new channel metadata until the completed signed build and every referenced artifact pass release validation; retained historical artifacts remain available for differential updates. +- Signed installed artifacts update successfully from the previous supported release on each release-blocking platform. + +## Review decisions + +| 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 | +| 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 | +| Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation | + +## Risks + +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. + +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. + +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 new file mode 100644 index 0000000000..5108492ba6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -0,0 +1,177 @@ +# Agent Note: 打包并更新 Electron 桌面应用 + +Status: implemented + +[English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 + +## 问题 + +DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。 + +桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。 + +当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 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` 解析。 + +一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。 + +浏览器 Web UI、dsh 后端、现有 `dsh plugin` CLI、用户 npm 和用户 pnpm 都不能修改该 profile。CLI 保留 `desktop` 名称的所有大小写变体,并拒绝针对它的启动、配置 dump 和插件管理请求。Electron 在项目恢复或 Host 启动前获取进程生命周期单实例锁;后续启动只会聚焦或重建主窗口,不会接触 profile 状态。Electron-only GUI 通过 preload 发送结构化安装、删除和更新请求;Electron 只调用其内置 pnpm。 + +## 归属 + +| Owner | 职责 | +|---|---| +| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 | +| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 | +| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` | +| 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | +| 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 | +| 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 | +| 通过 npm 安装的 dsh | 自己的可执行安装和用户管理的 profile;不能访问保留 desktop profile 或包状态 | + +渲染进程使用 `nodeIntegration: false`、`contextIsolation: true` 和 `sandbox: true`。Preload 暴露类型化 RPC、生命周期、更新、locale 与桌面插件操作,而不暴露原始 `ipcRenderer`、文件系统访问、shell 命令或 pnpm 参数。Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback;菜单、原生对话框与插件管理渲染进程使用这些由 locale 持有的文案。 + +## 文件系统布局 + +```text +~/.dsh/ + desktop/ + staging//profile/ + rollback/profile/ + pending.json + lock + pnpm/ + store/ + cache/ + state/ + config/ + profiles/ + desktop/ + package.json + pnpm-lock.yaml + pnpm-workspace.yaml + desktop-release.json + desktop-packages.json + desktop-packages/ + 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`。 + +## 安装与解析 + +安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `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 才可以恢复。 + +打包 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。 + +## 更新与恢复 + +Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 + +新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 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` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。 + +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 中。 + +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 中。 + +打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。 + +在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 + +## 实现 + +| 表面 | 实现 | +|---|---| +| 壳 | `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 录制仍是发布环境门槛。 | + +`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 + +## 考虑过的替代方案 + +**使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。 + +**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** JSON IPC 可以只保留一种消息机制,但会膨胀每个请求与响应消息体、在两个进程中构造大字符串、在分派前缓冲完整请求,还会再次编码 RPC JSON 中已经表示为 Base64 的图片字节。原始分帧管道保留明确的带版本协议,同时不要求 Electron 与上游 Node.js 共享 V8 序列化行为。 + +**把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。 + +**复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。 + +**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。 + +**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。 + +**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 + +**把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。 + +**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。 + +**让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。 + +## 结果 + +- 没有系统 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 安装的桌面插件。 +- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。 +- Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 +- 后端与浏览器应用不能修改桌面包。 +- npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 +- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 +- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 +- 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 +- 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 +- 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 +- Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。 +- Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。 +- 目标更新只有在已完成签名的构建及其引用的每个产物通过发布校验后才能暴露新频道元数据;保留的历史产物继续供差分更新使用。 +- 每个发布阻断平台上的签名已安装产物均能从上一个受支持版本成功更新。 + +## 评审决策 + +| 决策 | 建议 | +|---|---| +| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 | +| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile | +| 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | +| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | +| 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | +| 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | + +## 风险 + +插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。 + +更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。 + +通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 + +不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。 + +代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 diff --git a/.gitattributes b/.gitattributes index e0a432111d..ac01e65042 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,10 @@ # The repo's canonical text form is LF, enforced at checkout too: no smudge # boundary between working tree and repo, so byte-level gates (verify-* # comparisons, blob hashing, coverage offsets) see one form on every host. -# If a file class ever genuinely needs CRLF in the working tree (.bat/.cmd -# for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the -# in-repo form stays LF; CRLF becomes checkout-time presentation only. +# Windows command scripts require CRLF in the working tree for cmd.exe; the +# override after the default keeps the in-repo form normalized as text. * text=auto eol=lf +*.cmd text eol=crlf *.pdf binary diff --git a/.gitignore b/.gitignore index d445628213..a954fe5d85 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ tmp/ .idea mise.toml dist-exe/ +/dist/ +apps/desktop/.desktop-build/ python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-* python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ python/**/__pycache__/ diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1be29ddbaf..a43b428f74 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -68,6 +68,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`compression`](https://github.com/expressjs/compression) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | +| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT | @@ -98,6 +99,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`react-dom`](https://github.com/facebook/react) | MIT | | [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT | | [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT | +| [`semver`](https://github.com/npm/node-semver) | ISC | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | @@ -140,7 +142,9 @@ External packages **directly declared** only by repository tooling, test infrast | Package | License | | --- | --- | +| [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3) | Apache-2.0 | | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | +| [`@electron/notarize`](https://github.com/electron/notarize) | MIT | | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT | | [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | | [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | @@ -158,6 +162,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/semver`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -166,13 +171,17 @@ External packages **directly declared** only by repository tooling, test infrast | [`@vitest/coverage-v8`](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 | | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | | [`debug`](https://github.com/debug-js/debug) | MIT | +| [`electron`](https://github.com/electron/electron) | MIT | +| [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | +| [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | | [`http-server`](https://github.com/http-party/http-server) | MIT | | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause | @@ -181,12 +190,15 @@ External packages **directly declared** only by repository tooling, test infrast | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | | [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | +| [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`publint`](https://github.com/publint/publint) | MIT | | [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | | [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 | | [`tsdown`](https://github.com/rolldown/tsdown) | MIT | | [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | | [`vite`](https://github.com/vitejs/vite) | MIT | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 9b6e5e5944..405b42e28f 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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/cli/README.md -README.md: 850a9371c1515d1e0219e9a685b638063c498ff4 -README.zh.md: 02de213d7a7158971b445511a00661183b37234c +README.md: adab66bb1d7ed46039248a57c8722964f5aebd33 +README.zh.md: 4887a67872a569a0a54f0378aaceffe51cdc43c1 diff --git a/apps/cli/README.md b/apps/cli/README.md index 850a9371c1..adab66bb1d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -16,7 +16,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are | `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it. ## App arguments diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 02de213d7a..4887a67872 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -16,7 +16,7 @@ | `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI 会拒绝针对它的启动、配置 dump 和插件管理请求。 ## 应用参数 diff --git a/apps/cli/package.json b/apps/cli/package.json index 54cc008bc7..c9807d7941 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -104,6 +104,8 @@ "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 27d92dcf66..1894f073c6 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -60,6 +60,12 @@ interface BootOptions { */ const collect = (value: string, previous: string[] = []): string[] => [...previous, value] +function rejectElectronProfile(program: Command, profile: string): void { + if (profile.toLowerCase() === 'desktop') { + program.error('error: profile "desktop" is managed exclusively by the Electron application') + } +} + /** The launcher's own help text; each app prints its own. */ const HELP_EXAMPLES = ` Examples: @@ -141,6 +147,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc } const profile = options.profile if (profile === '') program.error('error: --profile needs a name') + rejectElectronProfile(program, profile) resolved = resolveBoot(program, profile, options, args) }) @@ -176,6 +183,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .action((args: string[], options: { profile: string }) => { rejectParentOptions('plugin') if (options.profile === '') program.error('error: --profile needs a name') + rejectElectronProfile(plugin, options.profile) if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add )') resolved = { mode: 'plugin', profile: options.profile, args } }) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index b76326d799..a4ce2668fb 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -95,6 +95,12 @@ describe('parseDshArgs', () => { expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) + expect(exitCode(['--profile', 'desktop'])).toBe(1) + expect(exitCode(['--profile', 'Desktop'])).toBe(1) + expect(exitCode(['--profile', 'DESKTOP'])).toBe(1) + expect(exitCode(['--profile', 'desktop', '--dump-config'])).toBe(1) + expect(exitCode(['plugin', '--profile', 'desktop', 'add', 'x'])).toBe(1) + expect(exitCode(['plugin', '--profile', 'Desktop', 'add', 'x'])).toBe(1) expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index fc7aa1c8b4..4c2331ed0b 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,10 +1,11 @@ import { fileURLToPath } from 'node:url' -import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const overlayPath = process.argv[2] if (overlayPath === undefined) throw new Error('dsh-badge snapshot requires an overlay path') @@ -23,7 +24,7 @@ try { id: agentId, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', send: () => {}, followup: () => {}, diff --git a/apps/cli/tests/profiles/headless/tests/harness.ts b/apps/cli/tests/profiles/headless/tests/harness.ts index fd8fa81384..ee98abba6b 100644 --- a/apps/cli/tests/profiles/headless/tests/harness.ts +++ b/apps/cli/tests/profiles/headless/tests/harness.ts @@ -2,7 +2,6 @@ import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -56,7 +55,6 @@ export interface CodingHarnessOptions { export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: options.personaPrefix ?? '' }, }) diff --git a/apps/desktop-host/config/desktop.cordis.patch.yml b/apps/desktop-host/config/desktop.cordis.patch.yml new file mode 100644 index 0000000000..92f4df6ddf --- /dev/null +++ b/apps/desktop-host/config/desktop.cordis.patch.yml @@ -0,0 +1,34 @@ +# Electron reuses the browser composition without its network and browser-launch rows. + +- id: web-startup + disabled: true + +- id: webserver + disabled: true + +- id: web-runtime + disabled: true + +- id: client-hmr + disabled: true + +- id: open-in-app + disabled: true + +- id: ui-open-in-app + disabled: true + +- id: directory-picker + disabled: true + +- id: connection + inject: + - credentials + config: {} + +- insert: + - id: directory-picker-native + name: '@deepseek-ai/dsh-host-directory-picker-native' + + - id: ui-directory-picker-native + name: '@deepseek-ai/dsh-client-ui-directory-picker-native' diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json new file mode 100644 index 0000000000..75222a8f43 --- /dev/null +++ b/apps/desktop-host/package.json @@ -0,0 +1,28 @@ +{ + "name": "@deepseek-ai/dsh-desktop-host", + "description": "Private upstream-Node host process for the Electron desktop application", + "version": "0.1.3-alpha.2", + "private": true, + "license": "MIT", + "type": "module", + "main": "lib/index.js", + "files": [ + "lib/index.js", + "config/desktop.cordis.patch.yml" + ], + "dependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^" + } +} diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts new file mode 100644 index 0000000000..e85badba04 --- /dev/null +++ b/apps/desktop-host/src/index.ts @@ -0,0 +1,587 @@ +/** + * Electron child-process entry: boots the desktop project without a listening + * socket and carries API plus validated Web assets over framed byte pipes. + * @module @deepseek-ai/dsh-desktop-host + */ + +import { createRequire } from 'node:module' +import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { once } from 'node:events' +import { readFile } from 'node:fs/promises' +import { dirname, extname, join, normalize, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { + boot, + composeEntries, + loadLayeredEnv, + loadProfileDirectory, + loadOverlayPatches, +} from '@deepseek-ai/dsh-app-boot' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' +import type {} from '@deepseek-ai/dsh-api-gateway' +import type { ConnectionFetchHandler } from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-client-modules' +import { renderIndexInjections, type IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { + DESKTOP_HOST_PROTOCOL_VERSION, + DESKTOP_PIPE_CHUNK_BYTES, + DESKTOP_REQUEST_PIPE_FD, + DESKTOP_RESPONSE_PIPE_FD, + DesktopHostRequestDecoder, + encodeDesktopResponseData, + encodeDesktopResponseEnd, + encodeDesktopResponseError, + encodeDesktopResponseStart, + type DesktopHostRequestFrame, +} from './wire.ts' + +export { DESKTOP_HOST_PROTOCOL_VERSION } from './wire.ts' + +/** One request forwarded from Electron's `dsh-app://` handler. */ +export interface DesktopHostFetchCommand { + readonly streamId: number + readonly request: { + readonly url: string + readonly method: string + readonly headers: readonly [string, string][] + } +} + +/** Commands accepted by the desktop child process. */ +export type DesktopHostCommand = { + readonly type: 'shutdown' +} + +/** Events emitted by the desktop child process. */ +export type DesktopHostEvent = { + readonly type: 'ready' + readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly dshVersion: string +} | { + readonly type: 'fatal' + readonly message: string +} + +/** Controller returned to tests and the self-executing process entry. */ +export interface DesktopHostController { + /** Installed dsh version carried by this host. */ + readonly dshVersion: string + /** Dispatch one custom-protocol request and stream its response to the response pipe. */ + fetch(command: DesktopHostFetchCommand, body: ReadableStream | null): Promise + /** Abort one in-flight request. */ + cancel(streamId: number): void + /** Stop accepting messages and await complete host teardown. */ + dispose(): Promise +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isDesktopHostCommand(message: unknown): message is DesktopHostCommand { + return typeof message === 'object' && message !== null && 'type' in message + && (message as Record).type === 'shutdown' +} + +interface PackageManifest { + readonly name?: string + readonly version?: string +} + +const DESKTOP_PATCH = fileURLToPath(new URL('../config/desktop.cordis.patch.yml', import.meta.url)) +const ROOT_CONFIG = '# Electron desktop composition root; package transactions own this file.\n[]\n' +const ROOT_CONFIG_FILENAME = 'desktop.cordis.yml' +const DESKTOP_STREAM_PATH = '/.dsh/remote-stream' + +const DESKTOP_TRANSPORT_SCRIPT = `globalThis.__DSH_TRANSPORT__={ + ownsHost:true, + async *openStream(endpoint,payload,signal){ + const response=await fetch(${JSON.stringify(DESKTOP_STREAM_PATH)},{ + method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({endpoint,payload}),signal + }) + if(!response.ok||response.body===null)throw new Error('desktop stream transport failed: HTTP '+response.status) + const reader=response.body.getReader(),decoder=new TextDecoder() + let pending='' + for(;;){ + const {done,value}=await reader.read() + pending+=decoder.decode(value,{stream:!done}) + let newline + while((newline=pending.indexOf('\\n'))!==-1){ + const line=pending.slice(0,newline);pending=pending.slice(newline+1) + if(line!=='')yield JSON.parse(line) + } + if(done)break + } + if(pending!=='')yield JSON.parse(pending) + } +}` + +const MIME: Readonly> = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.webmanifest': 'application/manifest+json', +} + +function readManifest(path: string): PackageManifest { + const value: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!isRecord(value)) throw new Error(`dsh desktop: ${path} must contain a package manifest`) + return { + ...(typeof value.name === 'string' ? { name: value.name } : {}), + ...(typeof value.version === 'string' ? { version: value.version } : {}), + } +} + +function packageManifestPath(projectDir: string, packageName: string): string { + const path = join(projectDir, 'node_modules', ...packageName.split('/'), 'package.json') + if (!existsSync(path)) throw new Error(`dsh desktop: installed package ${JSON.stringify(packageName)} has no manifest`) + return path +} + +function isProjectPath(projectDir: string, target: string): boolean { + const root = realpathSync(projectDir) + const path = realpathSync(target) + return path === root || path.startsWith(root + sep) +} + +function desktopPatches(projectDir: string, allowLinkedPackages: boolean): PatchOptions[] { + const dshRoot = dirname(packageManifestPath(projectDir, '@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`) + } + } + const layers = [ + ...profile.layers.map(layer => layer.patches), + profile.patches, + loadOverlayPatches('dsh desktop', DESKTOP_PATCH), + ] + const rows = new Map(composeEntries(layers).flatMap(row => typeof row.id === 'string' ? [[row.id, row] as const] : [])) + const agentPresets = rows.get('agent-presets') + if (agentPresets !== undefined) { + layers.push([{ + id: 'agent-presets', + config: { + ...(agentPresets.config ?? {}) as Record, + roots: [{ path: join(dshRoot, 'config', 'agent-presets'), trust: 'system' }], + }, + }]) + } + return layers.flat() +} + +function dshVersion(projectDir: string): string { + const manifest = readManifest(packageManifestPath(projectDir, '@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')) + const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') + const distRoot = realpathSync(dirname(distIndex)) + const renderIndex = async (): Promise => { + const rows: IndexInjection[] = [{ kind: 'script', placement: 'head', text: DESKTOP_TRANSPORT_SCRIPT }] + ctx.emit('webserver/index-inject', rows) + const body = renderIndexInjections(await readFile(distIndex, 'utf8'), rows) + return new Response(body, { headers: { 'content-type': MIME['.html'] ?? 'text/html; charset=utf-8' } }) + } + return { + requestBodyMode: () => 'buffered', + async fetch(request): Promise { + if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 }) + const url = new URL(request.url) + if (url.pathname.startsWith('/plugins/')) return ctx.clientModules.fetchBundle(request) + let pathname: string + try { + pathname = decodeURIComponent(url.pathname) + } catch { + return new Response(null, { status: 400 }) + } + if (pathname === '/' || pathname === '/index.html') return renderIndex() + const target = resolve(normalize(join(distRoot, pathname))) + if (target !== distRoot && !target.startsWith(distRoot + sep)) return new Response(null, { status: 403 }) + try { + const realTarget = realpathSync(target) + if (realTarget !== distRoot && !realTarget.startsWith(distRoot + sep)) return new Response(null, { status: 403 }) + return new Response(request.method === 'HEAD' ? null : await readFile(realTarget), { + headers: { 'content-type': MIME[extname(realTarget)] ?? 'application/octet-stream' }, + }) + } catch { + return renderIndex() + } + }, + } +} + +function remoteStreamHandler(ctx: Context): ConnectionFetchHandler { + return { + requestBodyMode: () => 'buffered', + async fetch(request): Promise { + if (request.method !== 'POST') return new Response(null, { status: 405 }) + const gateway = ctx.get('typertGateway') + if (gateway === undefined) return new Response('gateway unavailable', { status: 503 }) + let body: unknown + try { + body = await request.json() + } catch { + return new Response('body is not JSON', { status: 400 }) + } + if (!isRecord(body) || typeof body.endpoint !== 'string') { + return new Response('invalid stream request', { status: 400 }) + } + const abort = new AbortController() + const cancel = (): void => { abort.abort(request.signal.reason) } + request.signal.addEventListener('abort', cancel, { once: true }) + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + try { + const values = await gateway.wireStream.open(body.endpoint as string, body.payload, abort.signal) + for await (const value of values) { + controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`)) + } + controller.close() + } catch (error) { + controller.error(error) + } finally { + request.signal.removeEventListener('abort', cancel) + } + }, + cancel(reason) { + abort.abort(reason) + request.signal.removeEventListener('abort', cancel) + }, + }) + return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } }) + }, + } +} + +interface NodeRequestInit extends RequestInit { + readonly duplex?: 'half' +} + +/** + * Boot one installed desktop npm project. + * @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( + projectDir: string, + writeResponse: (frame: Buffer) => Promise, + options: { allowLinkedPackages?: boolean } = {}, +): Promise { + const absoluteProject = resolve(projectDir) + mkdirSync(absoluteProject, { recursive: true }) + const rootConfig = join(absoluteProject, ROOT_CONFIG_FILENAME) + writeFileSync(rootConfig, ROOT_CONFIG) + const environment = loadLayeredEnv('dsh desktop') + let current: Context | undefined + const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches( + absoluteProject, + options.allowLinkedPackages === true, + )), (hostCtx) => { + current = hostCtx + hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment) + provideCmdline(hostCtx, { args: [], exit: () => {} }) + }) + current = ctx + const connection = ctx.get('connection') + const clientModules = ctx.get('clientModules') + const gateway = ctx.get('typertGateway') + if (connection === undefined || clientModules === undefined || gateway === undefined) { + await ctx.fiber.dispose() + throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules') + } + const api = connection.createSharedFetchHandler('/api') + const assets = assetHandler(ctx, absoluteProject) + const streams = remoteStreamHandler(ctx) + const requests = new Map() + let disposing: Promise | undefined + + const dispose = async (): Promise => { + disposing ??= (async () => { + for (const controller of requests.values()) controller.abort() + requests.clear() + await current?.fiber.dispose() + current = undefined + })() + await disposing + } + + return { + dshVersion: dshVersion(absoluteProject), + cancel(streamId) { + requests.get(streamId)?.abort() + }, + async fetch(command, body) { + if (disposing !== undefined) throw new Error('dsh desktop: host is disposing') + const controller = new AbortController() + requests.set(command.streamId, controller) + try { + const url = new URL(command.request.url) + const init: NodeRequestInit = { + method: command.request.method, + headers: new Headers(command.request.headers.map(([name, value]) => [name, value] as [string, string])), + ...(body === null ? {} : { body, duplex: 'half' }), + signal: controller.signal, + } + const request = new Request(url, init) + const response = url.pathname === DESKTOP_STREAM_PATH + ? await streams.fetch(request) + : url.pathname.startsWith('/api/') + ? await api.fetch(request) + : await assets.fetch(request) + await writeResponse(encodeDesktopResponseStart(command.streamId, { + status: response.status, + headers: [...response.headers.entries()], + hasBody: response.body !== null, + })) + if (response.body !== null) { + for await (const chunk of response.body) { + const bytes = Buffer.from(chunk) + for (let offset = 0; offset < bytes.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) { + await writeResponse(encodeDesktopResponseData( + command.streamId, + bytes.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES), + )) + } + } + } + await writeResponse(encodeDesktopResponseEnd(command.streamId)) + } catch (error) { + if (!controller.signal.aborted) { + await writeResponse(encodeDesktopResponseError( + command.streamId, + error instanceof Error ? error.message : String(error), + )) + } + } finally { + requests.delete(command.streamId) + } + }, + dispose, + } +} + +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 option = process.argv[3] + if (option !== undefined && option !== '--allow-linked-profile') { + throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`) + } + const requestPipe = createReadStream('', { fd: DESKTOP_REQUEST_PIPE_FD, autoClose: false }) + const responsePipe = createWriteStream('', { fd: DESKTOP_RESPONSE_PIPE_FD, autoClose: false }) + let responseWriteTail: Promise = Promise.resolve() + const writeResponse = (frame: Buffer): Promise => { + const write = responseWriteTail.then(async () => { + if (responsePipe.destroyed) throw new Error('dsh desktop: Electron response pipe is unavailable') + if (!responsePipe.write(frame)) await once(responsePipe, 'drain') + }) + responseWriteTail = write.catch(() => undefined) + return write + } + const send = (event: DesktopHostEvent): void => { + if (process.send === undefined || !process.connected) return + try { + process.send(event) + } catch (error) { + // A concurrent parent disconnect owns teardown; only that closed-channel + // condition is safe to discard while streamed responses unwind. + if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error + } + } + const controller = await runDesktopHost(projectDir, writeResponse, { allowLinkedPackages: option !== undefined }) + send({ + type: 'ready', + protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + dshVersion: controller.dshVersion, + }) + const decoder = new DesktopHostRequestDecoder() + const requestBodies = new Map>() + const blockedRequests = new Set() + const discardedRequestBodies = new Set() + const runs = new Set>() + let lastStreamId = 0 + let requestedExitCode = 0 + let stopping: Promise | undefined + + const resumeRequestPipe = (): void => { + if (blockedRequests.size === 0) requestPipe.resume() + } + + const stop = (exitCode = 0): Promise => { + requestedExitCode = Math.max(requestedExitCode, exitCode) + stopping ??= (async () => { + requestPipe.pause() + requestPipe.removeAllListeners('data') + const stopped = new Error('dsh desktop: Host is stopping') + for (const body of requestBodies.values()) body.error(stopped) + requestBodies.clear() + blockedRequests.clear() + discardedRequestBodies.clear() + requestPipe.destroy() + closeSync(DESKTOP_REQUEST_PIPE_FD) + await controller.dispose() + await Promise.allSettled([...runs]) + await responseWriteTail.catch(() => undefined) + if (!responsePipe.destroyed) { + await new Promise((resolvePromise) => { responsePipe.end(resolvePromise) }) + responsePipe.destroy() + } + closeSync(DESKTOP_RESPONSE_PIPE_FD) + if (process.connected) process.disconnect() + process.exitCode = requestedExitCode + })() + return stopping + } + + const failTransport = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error) + send({ type: 'fatal', message }) + void stop(1) + } + + const beginRequest = (frame: Extract): void => { + if (frame.streamId <= lastStreamId) { + throw new Error(`dsh desktop: Electron reused or reordered request stream ${String(frame.streamId)}`) + } + lastStreamId = frame.streamId + let body: ReadableStream | null = null + if (frame.hasBody) { + body = new ReadableStream({ + start(controllerOfBody) { + requestBodies.set(frame.streamId, controllerOfBody) + }, + pull() { + blockedRequests.delete(frame.streamId) + resumeRequestPipe() + }, + cancel() { + requestBodies.delete(frame.streamId) + blockedRequests.delete(frame.streamId) + controller.cancel(frame.streamId) + resumeRequestPipe() + }, + }) + } + const run = controller.fetch({ + streamId: frame.streamId, + request: { + url: frame.url, + method: frame.method, + headers: frame.headers, + }, + }, body) + runs.add(run) + void run.catch(failTransport).finally(() => { + runs.delete(run) + const openBody = requestBodies.get(frame.streamId) + if (openBody === undefined) return + openBody.error(new Error('dsh desktop: response completed before the request body ended')) + requestBodies.delete(frame.streamId) + blockedRequests.delete(frame.streamId) + discardedRequestBodies.add(frame.streamId) + resumeRequestPipe() + }) + } + + const handleRequestFrame = (frame: DesktopHostRequestFrame): void => { + switch (frame.type) { + case 'start': + beginRequest(frame) + return + case 'data': { + const body = requestBodies.get(frame.streamId) + if (body === undefined) { + if (discardedRequestBodies.has(frame.streamId)) return + throw new Error(`dsh desktop: Electron sent body data for inactive stream ${String(frame.streamId)}`) + } + body.enqueue(frame.data) + if ((body.desiredSize ?? 0) <= 0) { + blockedRequests.add(frame.streamId) + requestPipe.pause() + } + return + } + case 'end': { + const body = requestBodies.get(frame.streamId) + if (body === undefined) { + if (discardedRequestBodies.delete(frame.streamId)) return + throw new Error(`dsh desktop: Electron ended inactive body stream ${String(frame.streamId)}`) + } + body.close() + requestBodies.delete(frame.streamId) + blockedRequests.delete(frame.streamId) + resumeRequestPipe() + return + } + case 'cancel': { + if (frame.streamId > lastStreamId) { + throw new Error(`dsh desktop: Electron canceled unknown stream ${String(frame.streamId)}`) + } + const body = requestBodies.get(frame.streamId) + body?.error(new Error('dsh desktop: Electron canceled the request')) + requestBodies.delete(frame.streamId) + blockedRequests.delete(frame.streamId) + discardedRequestBodies.delete(frame.streamId) + controller.cancel(frame.streamId) + resumeRequestPipe() + return + } + default: + frame satisfies never + } + } + + requestPipe.on('data', (chunk: string | Buffer) => { + try { + for (const frame of decoder.push(Buffer.from(chunk))) handleRequestFrame(frame) + } catch (error) { + failTransport(error) + } + }) + requestPipe.once('end', () => { + if (stopping !== undefined) return + try { + decoder.finish() + failTransport(new Error('dsh desktop: Electron request pipe ended')) + } catch (error) { + failTransport(error) + } + }) + requestPipe.once('error', failTransport) + responsePipe.once('error', failTransport) + process.on('message', (message: unknown) => { + if (!isDesktopHostCommand(message)) { + send({ type: 'fatal', message: 'dsh desktop: invalid Electron IPC command' }) + void stop(1) + return + } + void stop() + }) + process.once('disconnect', () => { void stop() }) + process.once('SIGTERM', () => { void stop() }) + process.once('SIGINT', () => { void stop() }) +} + +if (import.meta.main) { + main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + if (process.send !== undefined) process.send({ type: 'fatal', message } satisfies DesktopHostEvent) + else process.stderr.write(`dsh desktop: ${message}\n`) + process.exitCode = 1 + }) +} diff --git a/apps/desktop-host/src/wire.ts b/apps/desktop-host/src/wire.ts new file mode 100644 index 0000000000..d5b2127c49 --- /dev/null +++ b/apps/desktop-host/src/wire.ts @@ -0,0 +1,184 @@ +/** Framed request and response bytes for the Electron Desktop Host transport. */ + +/** Protocol version shared with the Electron shell. */ +export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const + +/** Child descriptor that receives Electron request frames. */ +export const DESKTOP_REQUEST_PIPE_FD = 3 + +/** Child descriptor that emits Host response frames. */ +export const DESKTOP_RESPONSE_PIPE_FD = 4 + +/** Maximum raw body bytes carried by one data frame. */ +export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024 + +const FRAME_MAGIC = 0x44534833 +const FRAME_HEADER_BYTES = 13 +const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024 + +const REQUEST_FRAME_START = 1 +const REQUEST_FRAME_DATA = 2 +const REQUEST_FRAME_END = 3 +const REQUEST_FRAME_CANCEL = 4 + +const RESPONSE_FRAME_START = 1 +const RESPONSE_FRAME_DATA = 2 +const RESPONSE_FRAME_END = 3 +const RESPONSE_FRAME_ERROR = 4 +type ResponseFrameType = typeof RESPONSE_FRAME_START | typeof RESPONSE_FRAME_DATA + | typeof RESPONSE_FRAME_END | typeof RESPONSE_FRAME_ERROR + +/** One validated request-pipe frame. */ +export type DesktopHostRequestFrame = { + readonly type: 'start' + readonly streamId: number + readonly url: string + readonly method: string + readonly headers: readonly [string, string][] + readonly hasBody: boolean +} | { + readonly type: 'data' + readonly streamId: number + readonly data: Buffer +} | { + readonly type: 'end' | 'cancel' + readonly streamId: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isHeaders(value: unknown): value is readonly [string, string][] { + return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2 + && typeof header[0] === 'string' && typeof header[1] === 'string') +} + +function assertStreamId(streamId: number): void { + if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) { + throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`) + } +} + +function encodeFrame(type: ResponseFrameType, streamId: number, payload: Buffer): Buffer { + assertStreamId(streamId) + const limit = type === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES + if (payload.byteLength > limit) { + throw new Error(`dsh desktop: response pipe frame exceeds the ${String(limit)}-byte limit`) + } + const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength) + frame.writeUInt32BE(FRAME_MAGIC, 0) + frame.writeUInt8(type, 4) + frame.writeUInt32BE(streamId, 5) + frame.writeUInt32BE(payload.byteLength, 9) + payload.copy(frame, FRAME_HEADER_BYTES) + return frame +} + +function encodeJsonFrame(type: ResponseFrameType, streamId: number, value: unknown): Buffer { + return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8')) +} + +/** Encode response metadata before any body frames. */ +export function encodeDesktopResponseStart( + streamId: number, + response: { + readonly status: number + readonly headers: readonly [string, string][] + readonly hasBody: boolean + }, +): Buffer { + return encodeJsonFrame(RESPONSE_FRAME_START, streamId, response) +} + +/** Encode one bounded raw response-body chunk. */ +export function encodeDesktopResponseData(streamId: number, data: Uint8Array): Buffer { + return encodeFrame(RESPONSE_FRAME_DATA, streamId, Buffer.from(data)) +} + +/** Encode normal response completion. */ +export function encodeDesktopResponseEnd(streamId: number): Buffer { + return encodeFrame(RESPONSE_FRAME_END, streamId, Buffer.alloc(0)) +} + +/** Encode one response failure without exposing an Error object across processes. */ +export function encodeDesktopResponseError(streamId: number, message: string): Buffer { + return encodeJsonFrame(RESPONSE_FRAME_ERROR, streamId, { message }) +} + +/** Incrementally decode validated request frames from the Electron byte pipe. */ +export class DesktopHostRequestDecoder { + private buffer: Buffer = Buffer.alloc(0) + + /** + * Append bytes and return every complete request frame. + * @param chunk - next bytes read from the Electron request pipe. + * @returns complete frames in pipe order. + */ + push(chunk: Buffer): DesktopHostRequestFrame[] { + this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + const frames: DesktopHostRequestFrame[] = [] + for (;;) { + const frame = this.next() + if (frame === undefined) return frames + frames.push(frame) + } + } + + /** Reject EOF that splits a frame. */ + finish(): void { + if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Electron request pipe ended inside a frame') + } + + private next(): DesktopHostRequestFrame | undefined { + if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined + if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Electron request frame marker') + const rawType = this.buffer.readUInt8(4) + const streamId = this.buffer.readUInt32BE(5) + const payloadLength = this.buffer.readUInt32BE(9) + assertStreamId(streamId) + const limit = rawType === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES + if (payloadLength > limit) { + throw new Error(`dsh desktop: Electron request frame exceeds the ${String(limit)}-byte limit`) + } + const frameLength = FRAME_HEADER_BYTES + payloadLength + if (this.buffer.byteLength < frameLength) return undefined + const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength) + this.buffer = this.buffer.subarray(frameLength) + switch (rawType) { + case REQUEST_FRAME_START: + return this.parseStart(streamId, payload) + case REQUEST_FRAME_DATA: + return { type: 'data', streamId, data: payload } + case REQUEST_FRAME_END: + if (payloadLength !== 0) throw new Error('dsh desktop: Electron request end frame carried a payload') + return { type: 'end', streamId } + case REQUEST_FRAME_CANCEL: + if (payloadLength !== 0) throw new Error('dsh desktop: Electron request cancel frame carried a payload') + return { type: 'cancel', streamId } + default: + throw new Error(`dsh desktop: unknown Electron request frame type ${String(rawType)}`) + } + } + + private parseStart(streamId: number, payload: Buffer): DesktopHostRequestFrame { + let value: unknown + try { + value = JSON.parse(payload.toString('utf8')) as unknown + } catch (error) { + throw new Error(`dsh desktop: Electron request start payload is not JSON: ${error instanceof Error ? error.message : String(error)}`) + } + if (!isRecord(value) || typeof value.url !== 'string' || typeof value.method !== 'string' + || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') { + throw new Error('dsh desktop: invalid Electron request start payload') + } + return { + type: 'start', + streamId, + url: value.url, + method: value.method, + headers: value.headers, + hasBody: value.hasBody, + } + } +} diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json new file mode 100644 index 0000000000..192f76b926 --- /dev/null +++ b/apps/desktop-host/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../vendor/cordis" }, + { "path": "../../vendor/include" }, + { "path": "../../packages/api/gateway/tsconfig.host.json" }, + { "path": "../../packages/boot/app-boot" }, + { "path": "../../packages/boot/cmdline" }, + { "path": "../../packages/client/connection/tsconfig.host.json" }, + { "path": "../../packages/client/modules" }, + { "path": "../../packages/host/webserver" }, + { "path": "../../packages/util/launch-environment" } + ] +} diff --git a/apps/desktop-host/tsdown.config.ts b/apps/desktop-host/tsdown.config.ts new file mode 100644 index 0000000000..caaa1aefad --- /dev/null +++ b/apps/desktop-host/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: ['lib/types/index.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml new file mode 100644 index 0000000000..de168b99dd --- /dev/null +++ b/apps/desktop/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 apps/desktop/README.md +README.md: cf350a9d5e9577f5cf52c4371b32f16e278133f9 +README.zh.md: df48eb5903e21cae9ff041626c28d41d12e5c632 diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000000..cf350a9d5e --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,180 @@ +# DeepSeek Harness Desktop + +English | [中文](README.zh.md) + +The desktop application is an Electron shell around the dsh Web UI. It opens no listening port: a bundled upstream Node.js child boots the installed dsh project, versioned framed byte pipes carry Fetch requests and streaming responses without an outer Base64 envelope, Node IPC carries lifecycle control, and `dsh-app://` serves the matching client assets. + +## Key technical decisions + +| Decision | Why | Direct consequence | +|---|---|---| +| 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. | +| 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. | + +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. + +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 + +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. + +| 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. | + +Startup installs or reconciles the seed as one serialized transaction: + +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. + +## Develop + +`dev:desktop` builds the current Host, client bundles, Web frontend, and Electron shell, projects the built CLI and private Desktop Host packages with their workspace dependencies into a disposable desktop npm project, and launches Electron without downloading the packaged Node.js runtime or resolving dsh from npm: + +```sh +pnpm run dev:desktop +``` + +Development Harness state defaults to `apps/desktop/.desktop-build/development/home`, the disposable npm project lives at `apps/desktop/.desktop-build/development/project`, and Electron browser data lives at `apps/desktop/.desktop-build/development/electron-user-data`. Sessions, settings, credentials, package links, and browser data therefore stay out of the user's normal Harness home. An explicit `DSH_HOME` replaces only the development Harness home. Renderer DevTools opens automatically; Main, Renderer, and dsh Host debugging listen on ports 9229, 9222, and 9230. `DSH_DESKTOP_MAIN_INSPECT_PORT`, `DSH_DESKTOP_RENDERER_DEBUG_PORT`, and `DSH_DESKTOP_HOST_INSPECT_PORT` replace those ports, while `DSH_DESKTOP_OPEN_DEVTOOLS=0` keeps the detached Renderer tools closed. + +After an explicit build, `start:desktop` reconstructs the disposable project and launches the existing artifacts without building again: + +```sh +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. + +## Package + +The normal packaging path is one complete command. It performs release preparation before creating the host platform's installers and update metadata. Every target requires a reverse-DNS `DSH_DESKTOP_APP_ID`. macOS targets additionally require the electron-builder certificate qualifier in `DSH_DESKTOP_MACOS_SIGNING_IDENTITY`, its 10-character Apple Team ID in `DSH_DESKTOP_MACOS_TEAM_ID`, and one complete notarytool credential strategy. The App Store Connect API-key strategy uses these variables: + +```sh +export DSH_DESKTOP_APP_ID='' +export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='' +export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>' +export APPLE_API_KEY='' +export APPLE_API_KEY_ID='' +export APPLE_API_ISSUER='' +``` + +`prepare:desktop` is not a prerequisite: + +```sh +pnpm run package:desktop +``` + +Release automation uses fixed target commands so runtime preparation, seed installation, and electron-builder receive the same platform and architecture: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +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. + +### 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`. + +The update destination and upload credentials follow the selected deployment: + +| Environment | Public origin | COS bucket | COS credentials | +|---|---|---|---| +| `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | + +Package and upload one target under the same environment. For example, the default test deployment uses: + +```sh +export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com' +pnpm run package:desktop:mac:arm64 + +export DOWNLOAD_TEST_COS_BUCKET='' +export DOWNLOAD_TEST_COS_SECRET_ID='' +export DOWNLOAD_TEST_COS_SECRET_KEY='' +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 -- `. + +### 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: + +```powershell +$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer' +$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe' +$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '' +$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. + +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. + +Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +To inspect or troubleshoot the prepared host-target resources without invoking electron-builder, stop the same pipeline after preparation: + +```sh +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. + +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. + +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. + +## 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. + +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. + +## 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`. + +## Known limitations + +- The Web "Open In..." action is disabled in Desktop because its host plugin requires HTTP routes; Desktop does not provide a `webServer`. +- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment. +- Desktop plugins with dependency lifecycle scripts are rejected unless their package appears in the desktop project's reviewed `allowBuilds` policy. +- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, lockfiles, and package-manager state remain separate. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md new file mode 100644 index 0000000000..df48eb5903 --- /dev/null +++ b/apps/desktop/README.zh.md @@ -0,0 +1,180 @@ +# DeepSeek Harness 桌面端 + +[English](README.md) | 中文 + +桌面应用是包裹 dsh Web UI 的 Electron 壳。它不打开监听端口:内置的上游 Node.js 子进程启动已安装的 dsh 项目,带版本的分帧字节管道在没有外层 Base64 信封的情况下承载 Fetch 请求与流式响应,Node IPC 承载生命周期控制,`dsh-app://` 则提供与后端版本匹配的客户端资源。 + +## 关键技术决策 + +| 决策 | 原因 | 直接结果 | +|---|---|---| +| 发布身份 | 桌面壳 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 分片。外层安装包负责压缩,差分更新可以复用未变化的分片。 | +| 状态归属 | 共享可执行依赖图会让 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 发布。 | + +[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 配置。 + +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 内容。 + +| 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 解析已打包的核心包名。 | + +启动过程把 seed 安装或校准为一个串行事务: + +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 误判为陈旧事务。 + +## 开发 + +`dev:desktop` 会构建当前 Host、客户端 bundle、Web 前端和 Electron 壳,把已构建的 CLI 包、私有 Desktop Host 包及其 workspace 依赖投影为一次性桌面 npm 项目,然后直接启动 Electron;这条路径不下载安装包内的 Node.js,也不从 npm 解析 dsh: + +```sh +pnpm run dev:desktop +``` + +开发 Harness 状态默认写入 `apps/desktop/.desktop-build/development/home`,一次性 npm 项目位于 `apps/desktop/.desktop-build/development/project`,Electron 浏览器数据则位于 `apps/desktop/.desktop-build/development/electron-user-data`。因此,会话、设置、凭据、包链接和浏览器数据都不会进入用户正常使用的 Harness home;显式 `DSH_HOME` 只会替换开发 Harness home。Renderer DevTools 默认自动打开,Main、Renderer 和 dsh Host 调试端口依次为 9229、9222 和 9230。`DSH_DESKTOP_MAIN_INSPECT_PORT`、`DSH_DESKTOP_RENDERER_DEBUG_PORT` 与 `DSH_DESKTOP_HOST_INSPECT_PORT` 可以替换这些端口,`DSH_DESKTOP_OPEN_DEVTOOLS=0` 则保持 Renderer 调试窗口关闭。 + +显式构建完成后,`start:desktop` 会重新生成一次性项目,并跳过构建直接启动已有产物: + +```sh +pnpm run start:desktop +``` + +Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、发布 seed、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 + +## 打包 + +正常打包只需执行一条完整命令。该命令会先准备发布资源,再生成宿主平台的安装包与更新元数据。所有目标都要求通过 `DSH_DESKTOP_APP_ID` 提供反向域名形式的应用 ID。macOS 目标还要求通过 `DSH_DESKTOP_MACOS_SIGNING_IDENTITY` 提供 electron-builder 证书限定名,通过 `DSH_DESKTOP_MACOS_TEAM_ID` 提供对应的 10 字符 Apple Team ID,并提供一套完整的 notarytool 凭据。App Store Connect API Key 方式使用以下变量: + +```sh +export DSH_DESKTOP_APP_ID='' +export DSH_DESKTOP_MACOS_SIGNING_IDENTITY='' +export DSH_DESKTOP_MACOS_TEAM_ID='<10-character Apple Team ID>' +export APPLE_API_KEY='' +export APPLE_API_KEY_ID='' +export APPLE_API_ISSUER='' +``` + +无需提前执行 `prepare:desktop`: + +```sh +pnpm run package:desktop +``` + +发布自动化使用固定目标命令,确保运行时准备、seed 安装与 electron-builder 接收相同的平台和架构: + +```sh +pnpm run package:desktop:mac:arm64 +pnpm run package:desktop:mac:x64 +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` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 + +### 上传更新 + +`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`。 + +更新目标与上传凭据都与所选环境对应: + +| 环境 | 公开 origin | COS bucket | COS 凭据 | +|---|---|---|---| +| `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | +| `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | + +同一目标必须在同一环境下完成打包与上传。例如,默认测试环境使用: + +```sh +export DOWNLOAD_TEST_ORIGIN='https://desktop-updates.example.com' +pnpm run package:desktop:mac:arm64 + +export DOWNLOAD_TEST_COS_BUCKET='' +export DOWNLOAD_TEST_COS_SECRET_ID='' +export DOWNLOAD_TEST_COS_SECRET_KEY='' +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 身份变量。 + +### 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 目标前设置这四个输入: + +```powershell +$env:DSH_DESKTOP_WINDOWS_CER_FILE = 'C:\path\to\server.cer' +$env:DSH_DESKTOP_WINDOWS_SIGNTOOL = 'C:\path\to\the\validated\signtool.exe' +$env:DSH_DESKTOP_WINDOWS_KEY_CONTAINER = '' +$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 打包会失败,不会生成未签名产物。 + +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 用户或系统环境变量。 + +使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: + +```sh +pnpm run package:desktop:dir +pnpm run package:desktop:mac:arm64:dir +``` + +需要检查或诊断为宿主目标准备的资源而不调用 electron-builder 时,可以让同一流水线在准备完成后停止: + +```sh +pnpm run prepare:desktop +``` + +这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 seed 内容。 + +每条打包命令都会先执行仓库的正式构建,打包 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,但会替换该目标的未打包应用、诊断文件、完成记录与频道元数据。 + +未压缩产物包含四块相互独立的体积:Electron、离线 seed store 分片与本地 dsh tarball、上游 Node.js 与 pnpm 运行时,以及很小的桌面壳应用。分片不压缩,使外层 DMG、ZIP 或 NSIS 压缩器与差分更新器可以处理稳定的数据区间。文件系统占用不等于安装包下载大小,因此必须分别测量。打包应用首次启动时还会先把 seed store 解包到 `$DSH_HOME/desktop/pnpm/store`,再安装可写 profile,因此发布验证必须同时测量应用与 Harness home 的磁盘占用。 + +## 更新 + +打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。 + +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 发布环境选择构建所验证的应用身份与平台签名身份。 + +## 底层开发覆盖项 + +`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_SEED_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 + +## 已知限制 + +- Desktop 禁用 Web 的“在本地应用中打开”操作,因为其 Host 插件依赖 HTTP 路由,而 Desktop 不提供 `webServer`。 +- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。 +- 依赖包含 lifecycle script 的桌面插件,只有其包名进入桌面项目经过评审的 `allowBuilds` 策略后才能安装。 +- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活、锁文件与包管理器状态彼此隔离。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts new file mode 100644 index 0000000000..2f4b88de64 --- /dev/null +++ b/apps/desktop/electron-builder.config.d.mts @@ -0,0 +1,39 @@ +/** Electron-builder fields asserted by the Desktop release tests. */ +export interface DesktopElectronBuilderConfig { + readonly appId: string + readonly directories: { + readonly output: string + } + readonly extraResources: readonly [ + { readonly from: string, readonly to: 'runtime' }, + { readonly from: string, readonly to: 'seed' }, + ] + readonly mac: { + readonly identity: string | undefined + readonly forceCodeSigning: boolean + readonly notarize: boolean + } + readonly dmg: { + readonly sign: boolean + readonly writeUpdateInfo: boolean + } + readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise | undefined + readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] +} + +/** + * Create electron-builder configuration from one release environment. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no explicit target is present. + * @param hostArch - Build-host architecture used when no explicit target is present. + * @returns electron-builder configuration. + */ +export function createElectronBuilderConfig( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): DesktopElectronBuilderConfig + +declare const electronBuilderConfig: DesktopElectronBuilderConfig + +export default electronBuilderConfig diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs new file mode 100644 index 0000000000..c47bd6d7a9 --- /dev/null +++ b/apps/desktop/electron-builder.config.mjs @@ -0,0 +1,109 @@ +import { + resolveDesktopAppId, + resolveMacOSNotarizationEnvironment, + resolveMacOSSigningEnvironment, +} from './scripts/desktop-release-environment.mjs' +import { notarizeMacOSDiskImageArtifact } from './scripts/notarize-macos-disk-images.mjs' +import { verifyMacOSSignatureAfterSign } from './scripts/verify-macos-signature.mjs' +import { + createWindowsTokenSigner, + installWindowsNsisBootstrapSigner, +} from './scripts/windows-sign.mjs' +import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs' +import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs' + +/** + * Create electron-builder configuration from one release environment. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present. + * @param {string} hostArch - Build-host architecture used when no explicit target is present. + * @returns {object} electron-builder configuration. + */ +export function createElectronBuilderConfig( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + const appId = resolveDesktopAppId(env) + const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM + const resolvedPlatform = targetPlatform ?? hostPlatform + const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch + 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 + ? createWindowsTokenSigner({ + certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, + signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL, + tokenPin: env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, + keyContainer: env.DSH_DESKTOP_WINDOWS_KEY_CONTAINER, + }) + : undefined + if (windowsSigner !== undefined) { + installWindowsNsisBootstrapSigner({ sign: windowsSigner }) + } + const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) + const buildPaths = desktopTargetBuildPaths(update.target) + return { + appId, + productName: 'DeepSeek Harness', + artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', + directories: { output: buildPaths.artifacts }, + asar: true, + files: [ + 'lib/*.js', + 'lib/*.cjs', + 'renderer/**/*', + 'package.json', + ], + extraResources: [ + { from: buildPaths.runtime, to: 'runtime' }, + { from: buildPaths.seed, to: 'seed' }, + ], + mac: { + category: 'public.app-category.developer-tools', + identity: macOSSigning?.signingIdentity, + forceCodeSigning: true, + hardenedRuntime: true, + notarize: true, + target: ['dmg', 'zip'], + }, + dmg: { + sign: true, + writeUpdateInfo: false, + }, + afterSign: context => { + if (context.electronPlatformName !== 'darwin') return + verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) + }, + artifactBuildCompleted: artifact => { + if (!artifact.file.endsWith('.dmg')) return + return notarizeMacOSDiskImageArtifact( + artifact, + env, + macOSSigning ?? resolveMacOSSigningEnvironment(env), + ) + }, + win: { + forceCodeSigning: true, + signtoolOptions: { + sign: windowsSigner, + signingHashAlgorithms: ['sha256'], + }, + target: ['nsis'], + }, + linux: { + category: 'Development', + target: ['AppImage'], + }, + nsis: { + oneClick: false, + allowToChangeInstallationDirectory: true, + differentialPackage: true, + }, + publish: [{ provider: 'generic', url: update.publicUrl }], + } +} + +export default createElectronBuilderConfig() diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000000..ec0b802a17 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,51 @@ +{ + "name": "@deepseek-ai/dsh-desktop", + "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime", + "version": "0.1.3-alpha.2", + "private": true, + "license": "MIT", + "type": "module", + "main": "lib/main.js", + "scripts": { + "build": "tsc -b && tsdown", + "dev": "tsx scripts/dev.ts", + "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:package": "tsx scripts/package-target.ts --prepare-only", + "verify:mac-signature": "node scripts/verify-macos-signature.mjs", + "package": "tsx scripts/package-target.ts", + "package:dir": "tsx scripts/package-target.ts --dir", + "package:mac:arm64": "tsx scripts/package-target.ts mac-arm64", + "package:mac:arm64:dir": "tsx scripts/package-target.ts mac-arm64 --dir", + "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: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", + "upload:win:x64": "tsx scripts/upload-target.ts win-x64" + }, + "dependencies": { + "electron-updater": "^6.8.9", + "semver": "^7.8.5" + }, + "devDependencies": { + "@aws-sdk/client-s3": "3.1067.0", + "@deepseek-ai/dsh-home-paths": "workspace:^", + "@electron/notarize": "2.5.0", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.20.0", + "@types/semver": "^7.8.0", + "app-builder-lib": "26.15.3", + "electron": "^44.0.0", + "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.css b/apps/desktop/renderer/plugin-manager.css new file mode 100644 index 0000000000..89d1c1f939 --- /dev/null +++ b/apps/desktop/renderer/plugin-manager.css @@ -0,0 +1,108 @@ +:root { + color-scheme: light dark; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: Canvas; + color: CanvasText; +} + +body { + margin: 0; +} + +main { + max-width: 680px; + margin: 0 auto; + padding: 32px; +} + +header, +.install-row, +li { + display: flex; + align-items: center; + gap: 12px; +} + +header { + justify-content: space-between; +} + +h1, +h2, +p { + margin-top: 0; +} + +header p { + color: GrayText; +} + +form, +section { + margin-top: 28px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; +} + +input { + flex: 1; + min-width: 0; + padding: 9px 11px; + border: 1px solid ButtonBorder; + border-radius: 7px; + background: Field; + color: FieldText; +} + +button { + padding: 9px 14px; + border: 1px solid ButtonBorder; + border-radius: 7px; + background: AccentColor; + color: AccentColorText; + cursor: pointer; +} + +button.quiet, +li button { + background: ButtonFace; + color: ButtonText; +} + +button:disabled, +input:disabled { + opacity: 0.55; + cursor: wait; +} + +#status { + min-height: 1.5em; + margin-top: 16px; + color: GrayText; +} + +ul { + margin: 0; + padding: 0; + list-style: none; +} + +li { + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid ButtonBorder; +} + +.package-version { + color: GrayText; + margin-left: 8px; +} + +.package-actions { + display: flex; + gap: 8px; +} diff --git a/apps/desktop/renderer/plugin-manager.html b/apps/desktop/renderer/plugin-manager.html new file mode 100644 index 0000000000..03fbb4b8aa --- /dev/null +++ b/apps/desktop/renderer/plugin-manager.html @@ -0,0 +1,35 @@ + + + + + + + + + + +
+
+
+

+

+
+ +
+
+ +
+ + +
+
+

+
+

+
    +

    +
    +
    + + + diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js new file mode 100644 index 0000000000..a50de3a825 --- /dev/null +++ b/apps/desktop/renderer/plugin-manager.js @@ -0,0 +1,101 @@ +const api = window.dshDesktop + +async function main() { + const locale = await api.locale() + const messages = locale.messages + const message = (key, values = {}) => messages[key].replaceAll(/\{([^{}]+)\}/gu, (placeholder, name) => values[name] ?? placeholder) + document.documentElement.lang = locale.id + document.querySelector('#page-title').textContent = messages.pluginManagerTitle + document.querySelector('#title').textContent = messages.pluginManagerTitle + document.querySelector('#description').textContent = messages.pluginManagerDescription + document.querySelector('#refresh').textContent = messages.refresh + document.querySelector('#package-label').textContent = messages.npmPackage + document.querySelector('#install').textContent = messages.install + document.querySelector('#installed-heading').textContent = messages.installed + document.querySelector('#empty').textContent = messages.noPlugins + + const list = document.querySelector('#plugins') + const empty = document.querySelector('#empty') + const status = document.querySelector('#status') + const form = document.querySelector('#install-form') + const input = document.querySelector('#package-spec') + const refresh = document.querySelector('#refresh') + + function setBusy(busy, statusMessage = '') { + for (const control of document.querySelectorAll('button, input')) control.disabled = busy + status.textContent = statusMessage + } + + async function render() { + 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 + identity.append(document.createTextNode(plugin.name), version) + const remove = document.createElement('button') + remove.type = 'button' + remove.textContent = messages.remove + remove.addEventListener('click', () => void run( + () => api.plugins.remove(plugin.name), + message('removing', { name: plugin.name }), + )) + const update = document.createElement('button') + update.type = 'button' + update.textContent = messages.update + update.addEventListener('click', () => { + const next = window.prompt(message('targetVersion', { name: plugin.name }), plugin.version)?.trim() + if (next === undefined || next === '' || next === plugin.version) return + void run(() => api.plugins.update(plugin.name, next), message('updating', { name: plugin.name })) + }) + const actions = document.createElement('span') + actions.className = 'package-actions' + actions.append(update, remove) + item.append(identity, actions) + return item + })) + empty.hidden = plugins.length !== 0 + } + + async function run(operation, statusMessage) { + setBusy(true, statusMessage) + try { + await operation() + await render() + status.textContent = messages.operationComplete + } catch (error) { + status.textContent = error instanceof Error ? error.message : String(error) + } finally { + setBusy(false, status.textContent) + } + } + + async function load(statusMessage, success) { + setBusy(true, statusMessage) + try { + await render() + status.textContent = success + } catch (error) { + status.textContent = error instanceof Error ? error.message : String(error) + } finally { + setBusy(false, status.textContent) + } + } + + form.addEventListener('submit', (event) => { + event.preventDefault() + const spec = input.value.trim() + if (spec === '') return + void run(async () => { + await api.plugins.add(spec) + input.value = '' + }, message('installing', { spec })) + }) + refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed)) + + await load(messages.loadingPlugins, '') +} + +void main() diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts new file mode 100644 index 0000000000..b1661646e5 --- /dev/null +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -0,0 +1,90 @@ +/** Environment variable that selects the Desktop update deployment. */ +export const DESKTOP_AUTO_UPDATE_ENV: 'DSH_DESKTOP_AUTO_UPDATE_ENV' + +/** Supported Desktop update deployment. */ +export type DesktopAutoUpdateEnvironment = 'test' | 'production' + +/** Directory name of one supported Desktop release target. */ +export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' + +/** Public updater URL for one release target. */ +export interface DesktopAutoUpdateConfig { + readonly environment: DesktopAutoUpdateEnvironment + readonly target: DesktopAutoUpdateTarget + readonly origin: string + readonly publicUrl: string + readonly keyPrefix: string +} + +/** Public updater URL and private COS destination for one upload target. */ +export interface DesktopUploadConfig extends DesktopAutoUpdateConfig { + readonly bucket: string + readonly secretIdEnvName: string + readonly secretKeyEnvName: string +} + +/** + * Resolve the update deployment, defaulting local release work to test. + * @param env - Packaging or upload environment. + * @returns Validated deployment name. + */ +export function resolveDesktopAutoUpdateEnvironment( + env: NodeJS.ProcessEnv, +): DesktopAutoUpdateEnvironment + +/** + * Resolve one supported platform and architecture to its update directory. + * @param platform - Target Node.js platform. + * @param arch - Target Node.js architecture. + * @returns Update target directory. + */ +export function resolveDesktopAutoUpdateTarget( + platform: NodeJS.Platform, + arch: string, +): DesktopAutoUpdateTarget + +/** + * Return the local completion record filename for one packaged target. + * @param target - Supported release target. + * @returns Filename stored beside electron-builder artifacts. + */ +export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): string + +/** + * Return the electron-builder channel metadata filename for an application version. + * @param version - Desktop semantic version. + * @param platform - Target platform. + * @returns Channel metadata filename emitted for the target. + */ +export function desktopUpdateMetadataFilename( + version: string, + platform: NodeJS.Platform, +): string + +/** + * Resolve the public updater URL for one release target. + * @param env - Packaging or upload environment. + * @param platform - Target Node.js platform. + * @param arch - Target Node.js architecture. + * @returns Resolved updater configuration. + * @throws When the test deployment lacks a valid HTTPS origin. + */ +export function resolveDesktopAutoUpdateConfig( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + arch: string, +): DesktopAutoUpdateConfig + +/** + * Resolve the public updater URL and private COS destination for one upload target. + * @param env - Upload environment. + * @param platform - Target Node.js platform. + * @param arch - Target Node.js architecture. + * @returns Resolved upload configuration. + * @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + */ +export function resolveDesktopUploadConfig( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + arch: string, +): DesktopUploadConfig diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs new file mode 100644 index 0000000000..da3c36ccb5 --- /dev/null +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -0,0 +1,169 @@ +/** Resolve the Desktop auto-update channel and its Tencent COS destination. */ + +import { prerelease, valid } from 'semver' + +/** Environment variable that selects the Desktop update deployment. */ +export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' + +const UPDATE_ENVIRONMENTS = { + test: { + originEnvName: 'DOWNLOAD_TEST_ORIGIN', + fixedOrigin: undefined, + bucketEnvName: 'DOWNLOAD_TEST_COS_BUCKET', + secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', + }, + production: { + originEnvName: undefined, + fixedOrigin: 'https://download.deepseek.com', + bucketEnvName: 'DOWNLOAD_PROD_COS_BUCKET', + secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', + }, +} + +const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) + +/** + * Resolve the update deployment, defaulting local release work to test. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @returns {'test' | 'production'} Validated deployment name. + */ +export function resolveDesktopAutoUpdateEnvironment(env) { + const value = env[DESKTOP_AUTO_UPDATE_ENV]?.trim() || 'test' + if (value !== 'test' && value !== 'production') { + throw new Error(`desktop auto-update: ${DESKTOP_AUTO_UPDATE_ENV} must be "test" or "production"`) + } + return value +} + +/** + * Resolve one supported platform and architecture to its update directory. + * @param {NodeJS.Platform} platform - Target Node.js platform. + * @param {string} arch - Target Node.js architecture. + * @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Update target directory. + */ +export function resolveDesktopAutoUpdateTarget(platform, arch) { + const os = platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform + const target = `${os}-${arch}` + if (!UPDATE_TARGETS.has(target)) { + throw new Error(`desktop auto-update: unsupported target ${target}`) + } + return target +} + +/** + * Return the local completion record filename for one packaged target. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported release target. + * @returns {string} Filename stored beside electron-builder artifacts. + */ +export function desktopBuildRecordFilename(target) { + if (!UPDATE_TARGETS.has(target)) { + throw new Error(`desktop auto-update: unsupported target ${target}`) + } + return `${target}-release.json` +} + +/** + * Return the electron-builder channel metadata filename for an application version. + * @param {string} version - Desktop semantic version. + * @param {NodeJS.Platform} platform - Target platform. + * @returns {string} Channel metadata filename emitted for the target. + */ +export function desktopUpdateMetadataFilename(version, platform) { + if (valid(version) === null) { + throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`) + } + if (platform !== 'darwin' && platform !== 'win32') { + throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`) + } + const release = prerelease(version) + const channel = release === null ? 'latest' : String(release[0]) + return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml` +} + +/** + * Read one required release setting without accepting whitespace-only values. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {string} name - Environment variable to read. + * @returns {string} Trimmed setting. + */ +function requiredEnvironmentValue(env, name) { + const value = env[name]?.trim() + if (value === undefined || value === '') { + throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`) + } + return value +} + +/** + * Normalize an HTTPS origin and reject paths or credentials. + * @param {string} value - Candidate origin. + * @param {string} name - Environment variable used in diagnostics. + * @returns {string} Normalized HTTPS origin without a trailing slash. + */ +function httpsOrigin(value, name) { + let parsed + try { + parsed = new URL(value) + } + catch { + throw new Error(`desktop auto-update: ${name} must be an absolute HTTPS origin`) + } + if (parsed.protocol !== 'https:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.pathname !== '/' + || parsed.search !== '' + || parsed.hash !== '') { + throw new Error(`desktop auto-update: ${name} must be an absolute HTTPS origin without a path, credentials, query, or fragment`) + } + return parsed.origin +} + +/** + * Resolve the public updater URL for one release target. + * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. + * @param {NodeJS.Platform} platform - Target Node.js platform. + * @param {string} arch - Target Node.js architecture. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration. + * @throws {Error} When the test deployment lacks a valid HTTPS origin. + */ +export function resolveDesktopAutoUpdateConfig(env, platform, arch) { + const environment = resolveDesktopAutoUpdateEnvironment(env) + const target = resolveDesktopAutoUpdateTarget(platform, arch) + const deployment = UPDATE_ENVIRONMENTS[environment] + let origin = deployment.fixedOrigin + if (origin === undefined) { + const { originEnvName } = deployment + if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin') + origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName) + } + const keyPrefix = `_/harness/desktop/stable/${target}` + return { + environment, + target, + origin, + keyPrefix, + publicUrl: `${origin}/${keyPrefix}/`, + } +} + +/** + * Resolve the public updater URL and private COS destination for one upload target. + * @param {NodeJS.ProcessEnv} env - Upload environment. + * @param {NodeJS.Platform} platform - Target Node.js platform. + * @param {string} arch - Target Node.js architecture. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + */ +export function resolveDesktopUploadConfig(env, platform, arch) { + const update = resolveDesktopAutoUpdateConfig(env, platform, arch) + const deployment = UPDATE_ENVIRONMENTS[update.environment] + return { + ...update, + bucket: requiredEnvironmentValue(env, deployment.bucketEnvName), + secretIdEnvName: deployment.secretIdEnvName, + secretKeyEnvName: deployment.secretKeyEnvName, + } +} diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts new file mode 100644 index 0000000000..423e017b15 --- /dev/null +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -0,0 +1,49 @@ +import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs' + +/** Mutable target directories plus the shared immutable download cache. */ +export interface DesktopTargetBuildPaths { + readonly root: string + readonly artifacts: string + readonly runtime: string + readonly packageSet: string + readonly seed: string + readonly seedPnpm: string + readonly nodeExtract: string + readonly packedDsh: string + readonly packedVendor: string + readonly packedLandlock: string + readonly downloads: string +} + +/** + * Resolve the fixed build target selected by a packaging environment. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Supported Desktop target name. + */ +export function resolveDesktopBuildTarget( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): DesktopAutoUpdateTarget + +/** + * Return the mutable preparation and artifact directories owned by one release target. + * @param target - Supported Desktop target name. + * @returns Target paths plus the shared immutable download cache. + */ +export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths + +/** + * Resolve the paths owned by the target selected in a packaging environment. + * @param env - Packaging environment. + * @param hostPlatform - Build-host platform used when no target override exists. + * @param hostArch - Build-host architecture used when no target override exists. + * @returns Selected target paths. + */ +export function resolveDesktopTargetBuildPaths( + env?: NodeJS.ProcessEnv, + hostPlatform?: NodeJS.Platform, + hostArch?: string, +): DesktopTargetBuildPaths diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs new file mode 100644 index 0000000000..504136e1db --- /dev/null +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -0,0 +1,70 @@ +/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */ + +import { join, resolve } from 'node:path' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const BUILD_ROOT = join(APP_ROOT, '.desktop-build') +const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) + +/** + * Resolve the fixed build target selected by a packaging environment. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {'mac-arm64' | 'mac-x64' | 'win-x64'} Supported Desktop target name. + */ +export function resolveDesktopBuildTarget( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + const platform = env.DSH_DESKTOP_TARGET_PLATFORM ?? env.npm_config_platform ?? hostPlatform + const arch = env.DSH_DESKTOP_TARGET_ARCH ?? env.npm_config_arch ?? hostArch + const os = platform === 'darwin' ? 'mac' : platform === 'win32' || platform === 'win' ? 'win' : platform + const target = `${os}-${arch}` + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`desktop build paths: unsupported target ${target}`) + } + return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target) +} + +/** + * 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. + */ +export function desktopTargetBuildPaths(target) { + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`desktop build paths: unsupported target ${String(target)}`) + } + const root = join(BUILD_ROOT, 'targets', target) + const packed = join(root, 'packed') + return { + root, + artifacts: join(root, 'artifacts'), + runtime: join(root, 'runtime'), + packageSet: join(root, 'package-set'), + seed: join(root, 'seed'), + seedPnpm: join(root, 'seed-pnpm'), + nodeExtract: join(root, 'node-extract'), + packedDsh: join(packed, 'dsh'), + packedVendor: join(packed, 'vendor'), + packedLandlock: join(packed, 'landlock'), + downloads: join(BUILD_ROOT, 'downloads'), + } +} + +/** + * Resolve the paths owned by the target selected in a packaging environment. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists. + * @param {string} hostArch - Build-host architecture used when no target override exists. + * @returns {ReturnType} Selected target paths. + */ +export function resolveDesktopTargetBuildPaths( + env = process.env, + hostPlatform = process.platform, + hostArch = process.arch, +) { + return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) +} diff --git a/apps/desktop/scripts/desktop-release-environment.d.mts b/apps/desktop/scripts/desktop-release-environment.d.mts new file mode 100644 index 0000000000..ad48933f65 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-environment.d.mts @@ -0,0 +1,61 @@ +/** Environment variable that supplies the Electron application identifier. */ +export const DESKTOP_APP_ID_ENV: 'DSH_DESKTOP_APP_ID' + +/** Environment variable that supplies electron-builder's macOS certificate qualifier. */ +export const MACOS_SIGNING_IDENTITY_ENV: 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY' + +/** Environment variable that supplies the expected Apple Developer Team ID. */ +export const MACOS_TEAM_ID_ENV: 'DSH_DESKTOP_MACOS_TEAM_ID' + +/** Public identity expected on a macOS release. */ +export interface MacOSSigningEnvironment { + readonly signingIdentity: string + readonly teamId: string +} + +/** Apple ID credentials accepted by notarytool. */ +export interface MacOSAppleIdNotarizationEnvironment { + readonly appleId: string + readonly appleIdPassword: string + readonly teamId: string +} + +/** App Store Connect API credentials accepted by notarytool. */ +export interface MacOSApiKeyNotarizationEnvironment { + readonly appleApiKey: string + readonly appleApiKeyId: string + readonly appleApiIssuer: string +} + +/** Keychain profile accepted by notarytool. */ +export interface MacOSKeychainNotarizationEnvironment { + readonly keychainProfile: string + readonly keychain?: string +} + +/** One complete credential strategy accepted by notarytool. */ +export type MacOSNotarizationEnvironment = + | MacOSAppleIdNotarizationEnvironment + | MacOSApiKeyNotarizationEnvironment + | MacOSKeychainNotarizationEnvironment + +/** + * Resolve and validate the application identifier shared by every platform target. + * @param env - Packaging environment. + * @returns Reverse-DNS application identifier. + */ +export function resolveDesktopAppId(env: NodeJS.ProcessEnv): string + +/** + * Resolve and validate the public identity expected on a macOS release. + * @param env - Packaging environment. + * @returns Expected certificate qualifier and Team ID. + */ +export function resolveMacOSSigningEnvironment(env: NodeJS.ProcessEnv): MacOSSigningEnvironment + +/** + * Resolve one complete credential set accepted by Apple's notary service. + * @param env - Packaging environment. + * @returns Notary credentials without the submitted artifact path. + */ +export function resolveMacOSNotarizationEnvironment(env: NodeJS.ProcessEnv): MacOSNotarizationEnvironment diff --git a/apps/desktop/scripts/desktop-release-environment.mjs b/apps/desktop/scripts/desktop-release-environment.mjs new file mode 100644 index 0000000000..46746428d9 --- /dev/null +++ b/apps/desktop/scripts/desktop-release-environment.mjs @@ -0,0 +1,98 @@ +/** Resolve public release identifiers supplied by the packaging environment. */ + +/** Environment variable that supplies the Electron application identifier. */ +export const DESKTOP_APP_ID_ENV = 'DSH_DESKTOP_APP_ID' + +/** Environment variable that supplies electron-builder's macOS certificate qualifier. */ +export const MACOS_SIGNING_IDENTITY_ENV = 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY' + +/** Environment variable that supplies the expected Apple Developer Team ID. */ +export const MACOS_TEAM_ID_ENV = 'DSH_DESKTOP_MACOS_TEAM_ID' + +const APPLE_API_KEY_ENV = 'APPLE_API_KEY' +const APPLE_API_KEY_ID_ENV = 'APPLE_API_KEY_ID' +const APPLE_API_ISSUER_ENV = 'APPLE_API_ISSUER' +const APPLE_ID_ENV = 'APPLE_ID' +const APPLE_APP_SPECIFIC_PASSWORD_ENV = 'APPLE_APP_SPECIFIC_PASSWORD' +const APPLE_TEAM_ID_ENV = 'APPLE_TEAM_ID' +const APPLE_KEYCHAIN_ENV = 'APPLE_KEYCHAIN' +const APPLE_KEYCHAIN_PROFILE_ENV = 'APPLE_KEYCHAIN_PROFILE' + +/** + * Read one required non-empty environment variable. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {string} name - Required variable name. + * @returns {string} Trimmed variable value. + */ +function requireEnvironmentValue(env, name) { + const value = env[name]?.trim() + if (value === undefined || value === '') { + throw new Error(`desktop release environment: ${name} must be set to a non-empty value`) + } + return value +} + +/** + * Resolve and validate the application identifier shared by every platform target. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {string} Reverse-DNS application identifier. + */ +export function resolveDesktopAppId(env) { + const appId = requireEnvironmentValue(env, DESKTOP_APP_ID_ENV) + if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/u.test(appId)) { + throw new Error(`desktop release environment: ${DESKTOP_APP_ID_ENV} must be a reverse-DNS identifier`) + } + return appId +} + +/** + * Resolve and validate the public identity expected on a macOS release. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ signingIdentity: string, teamId: string }} Expected certificate qualifier and Team ID. + */ +export function resolveMacOSSigningEnvironment(env) { + const signingIdentity = requireEnvironmentValue(env, MACOS_SIGNING_IDENTITY_ENV) + if (signingIdentity.startsWith('Developer ID Application:')) { + throw new Error(`desktop release environment: ${MACOS_SIGNING_IDENTITY_ENV} must omit the "Developer ID Application:" prefix`) + } + const teamId = requireEnvironmentValue(env, MACOS_TEAM_ID_ENV) + if (!/^[A-Z0-9]{10}$/u.test(teamId)) { + throw new Error(`desktop release environment: ${MACOS_TEAM_ID_ENV} must contain 10 uppercase letters or digits`) + } + return { signingIdentity, teamId } +} + +/** + * Resolve one complete credential set accepted by Apple's notary service. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @returns {{ appleId: string, appleIdPassword: string, teamId: string } | { appleApiKey: string, appleApiKeyId: string, appleApiIssuer: string } | { keychainProfile: string, keychain?: string }} Notary credentials without the submitted artifact path. + */ +export function resolveMacOSNotarizationEnvironment(env) { + const appleIdValues = [env[APPLE_ID_ENV], env[APPLE_APP_SPECIFIC_PASSWORD_ENV], env[APPLE_TEAM_ID_ENV]] + if (appleIdValues.some(value => value !== undefined)) { + return { + appleId: requireEnvironmentValue(env, APPLE_ID_ENV), + appleIdPassword: requireEnvironmentValue(env, APPLE_APP_SPECIFIC_PASSWORD_ENV), + teamId: requireEnvironmentValue(env, APPLE_TEAM_ID_ENV), + } + } + + const apiKeyValues = [env[APPLE_API_KEY_ENV], env[APPLE_API_KEY_ID_ENV], env[APPLE_API_ISSUER_ENV]] + if (apiKeyValues.some(value => value !== undefined)) { + return { + appleApiKey: requireEnvironmentValue(env, APPLE_API_KEY_ENV), + appleApiKeyId: requireEnvironmentValue(env, APPLE_API_KEY_ID_ENV), + appleApiIssuer: requireEnvironmentValue(env, APPLE_API_ISSUER_ENV), + } + } + + const keychainProfile = env[APPLE_KEYCHAIN_PROFILE_ENV]?.trim() + if (keychainProfile !== undefined && keychainProfile !== '') { + const keychain = env[APPLE_KEYCHAIN_ENV]?.trim() + return keychain === undefined || keychain === '' + ? { keychainProfile } + : { keychainProfile, keychain } + } + + throw new Error('desktop release environment: macOS packaging requires APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER; APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID; or APPLE_KEYCHAIN_PROFILE') +} diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts new file mode 100644 index 0000000000..ff8a2f4868 --- /dev/null +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -0,0 +1,257 @@ +/** Validate packaged Desktop update artifacts before any network upload begins. */ + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { load } from 'js-yaml' +import type { DesktopPackageTargetName } from './package-target.ts' +import { + desktopBuildRecordFilename, + desktopUpdateMetadataFilename, + resolveDesktopUploadConfig, +} from './desktop-auto-update-environment.mjs' +import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') +const TARGETS = { + 'mac-arm64': { platform: 'darwin', arch: 'arm64', os: 'mac' }, + 'mac-x64': { platform: 'darwin', arch: 'x64', os: 'mac' }, + 'win-x64': { platform: 'win32', arch: 'x64', os: 'win' }, +} as const satisfies Record + +/** One local file and its final object metadata. */ +export interface DesktopUploadArtifact { + readonly path: string + readonly filename: string + readonly key: string + readonly contentType: string + readonly cacheControl: string + readonly channelMetadata: boolean +} + +/** A fully validated upload operation with channel metadata ordered last. */ +export interface DesktopUploadPlan { + readonly environment: 'test' | 'production' + readonly target: DesktopPackageTargetName + readonly version: string + readonly publicUrl: string + readonly bucket: string + readonly secretIdEnvName: string + readonly secretKeyEnvName: string + readonly artifacts: readonly DesktopUploadArtifact[] +} + +/** Filesystem and environment inputs used to validate one upload. */ +export interface DesktopUploadPlanOptions { + readonly environment?: NodeJS.ProcessEnv + readonly repositoryRoot?: string + readonly appRoot?: string + readonly artifactsRoot?: string +} + +interface UpdateFileInfo { + readonly filename: string + readonly size: number + readonly sha512: string +} + +function object(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`desktop upload: ${label} must be an object`) + } + return value as Record +} + +function stringField(value: unknown, label: string): string { + if (typeof value !== 'string' || value === '') { + throw new Error(`desktop upload: ${label} must be a non-empty string`) + } + return value +} + +function numberField(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`desktop upload: ${label} must be a positive integer`) + } + return value +} + +async function jsonFile(path: string, label: string): Promise> { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(path, 'utf8')) + } + catch (error) { + throw new Error(`desktop upload: cannot read ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`) + } + return object(parsed, label) +} + +async function manifestVersion(path: string, label: string): Promise { + return stringField((await jsonFile(path, label)).version, `${label}.version`) +} + +function updateFileInfo(value: unknown, label: string, expectedFilename: string): UpdateFileInfo { + const info = object(value, label) + const filename = stringField(info.url ?? info.path, `${label}.url`) + if (filename !== basename(filename) || filename !== expectedFilename) { + throw new Error(`desktop upload: ${label} must reference ${expectedFilename}, received ${filename}`) + } + return { + filename, + size: numberField(info.size, `${label}.size`), + sha512: stringField(info.sha512, `${label}.sha512`), + } +} + +async function sha512(path: string): Promise { + const hash = createHash('sha512') + for await (const chunk of createReadStream(path)) hash.update(chunk) + return hash.digest('base64') +} + +async function verifyChecksummedArtifact( + artifactsRoot: string, + info: UpdateFileInfo, +): Promise { + const path = join(artifactsRoot, info.filename) + const details = await stat(path).catch(() => undefined) + if (details === undefined || !details.isFile()) { + throw new Error(`desktop upload: missing artifact ${path}`) + } + if (details.size !== info.size) { + throw new Error(`desktop upload: ${info.filename} size ${details.size} does not match update metadata ${info.size}`) + } + const actual = await sha512(path) + if (actual !== info.sha512) { + throw new Error(`desktop upload: ${info.filename} SHA-512 does not match update metadata`) + } + return path +} + +async function requireArtifact(artifactsRoot: string, filename: string): Promise { + const path = join(artifactsRoot, filename) + const details = await stat(path).catch(() => undefined) + if (details === undefined || !details.isFile() || details.size === 0) { + throw new Error(`desktop upload: missing or empty artifact ${path}`) + } + return path +} + +function uploadArtifact( + path: string, + keyPrefix: string, + contentType: string, + channelMetadata = false, +): DesktopUploadArtifact { + const filename = basename(path) + return { + path, + filename, + key: `${keyPrefix}/${filename}`, + contentType, + cacheControl: channelMetadata + ? 'no-cache' + : 'public, max-age=31536000, immutable', + channelMetadata, + } +} + +/** + * Validate the completed package record, dsh version, update metadata, hashes, and target files. + * @param targetName - Fixed platform and architecture selected by the upload command. + * @param options - Optional filesystem roots and environment for tests or release automation. + * @returns An upload plan whose mutable channel metadata is the final entry. + */ +export async function createDesktopUploadPlan( + targetName: DesktopPackageTargetName, + options: DesktopUploadPlanOptions = {}, +): Promise { + const target = TARGETS[targetName] + if (target === undefined) { + throw new Error(`desktop upload: unsupported target ${String(targetName)}`) + } + const environment = options.environment ?? process.env + const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT + const appRoot = options.appRoot ?? APP_ROOT + const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts + const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package') + const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package') + if (dshVersion !== desktopVersion) { + throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`) + } + + const update = resolveDesktopUploadConfig(environment, target.platform, target.arch) + const buildRecord = await jsonFile( + join(artifactsRoot, desktopBuildRecordFilename(targetName)), + `${targetName} package completion record`, + ) + if (buildRecord.schemaVersion !== 1 + || buildRecord.target !== targetName + || buildRecord.version !== dshVersion + || buildRecord.environment !== update.environment + || buildRecord.publicUrl !== update.publicUrl) { + throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`) + } + + const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform) + const metadataPath = join(artifactsRoot, metadataFilename) + let metadataValue: unknown + try { + metadataValue = load(await readFile(metadataPath, 'utf8')) + } + catch (error) { + throw new Error(`desktop upload: cannot read update metadata at ${metadataPath}: ${error instanceof Error ? error.message : String(error)}`) + } + const metadata = object(metadataValue, metadataFilename) + const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`) + if (metadataVersion !== dshVersion) { + throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`) + } + if (!Array.isArray(metadata.files) || metadata.files.length !== 1) { + throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`) + } + + const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}` + const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe' + const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) + const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) + const artifacts: DesktopUploadArtifact[] = [] + + if (target.platform === 'darwin') { + const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) + const blockmapPath = await requireArtifact(artifactsRoot, `${base}.zip.blockmap`) + artifacts.push( + uploadArtifact(dmgPath, update.keyPrefix, 'application/x-apple-diskimage'), + uploadArtifact(updaterPath, update.keyPrefix, 'application/zip'), + uploadArtifact(blockmapPath, update.keyPrefix, 'application/octet-stream'), + ) + } + else { + const blockMapSize = object(metadata.files[0], `${metadataFilename}.files[0]`).blockMapSize + numberField(blockMapSize, `${metadataFilename}.files[0].blockMapSize`) + artifacts.push(uploadArtifact( + updaterPath, + update.keyPrefix, + 'application/vnd.microsoft.portable-executable', + )) + } + + artifacts.push(uploadArtifact(metadataPath, update.keyPrefix, 'application/yaml', true)) + return { + environment: update.environment, + target: targetName, + version: dshVersion, + publicUrl: update.publicUrl, + bucket: update.bucket, + secretIdEnvName: update.secretIdEnvName, + secretKeyEnvName: update.secretKeyEnvName, + artifacts, + } +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts new file mode 100644 index 0000000000..3ae65d12aa --- /dev/null +++ b/apps/desktop/scripts/dev.ts @@ -0,0 +1,118 @@ +/** Build and launch the unpackaged Electron shell against the current workspace. */ + +import { spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import type { DesktopRelease } from '../src/release.ts' +import { prepareDevelopmentProject } from './development-project.ts' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') +const BUILD_ROOT = join(APP_ROOT, '.desktop-build') +const DEVELOPMENT_ROOT = join(BUILD_ROOT, 'development') + +interface PackageManifest { + readonly version?: string +} + +function packageVersion(path: string, subject: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest + if (typeof manifest.version !== 'string') throw new Error(`desktop development: ${subject} has no version`) + return manifest.version +} + +function debugPort(name: string, fallback: number): number { + const value = process.env[name] + if (value === undefined || value === '') return fallback + const port = Number(value) + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error(`desktop development: ${name} must be an integer from 1 through 65535`) + } + return port +} + +async function run(command: string, args: readonly string[], cwd: string, environment = process.env): Promise { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd, env: environment, stdio: 'inherit' }) + child.once('error', reject) + child.once('exit', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop development: ${args.join(' ')} exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function runPackageScript(script: string, cwd: string): Promise { + const packageManager = process.env.npm_execpath + if (packageManager === undefined || packageManager === '') { + throw new Error('desktop development: invoke this launcher through pnpm run dev:desktop or start:desktop') + } + await run(process.execPath, [packageManager, 'run', script], cwd) +} + +async function launchElectron(projectDir: string): 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') + const mainPort = debugPort('DSH_DESKTOP_MAIN_INSPECT_PORT', 9229) + const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222) + const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230) + const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home')) + const userData = join(DEVELOPMENT_ROOT, 'electron-user-data') + 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', + ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', + } + console.log(`desktop development: DSH_HOME=${home}`) + console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`) + await run(electron, [ + `--inspect=127.0.0.1:${String(mainPort)}`, + `--remote-debugging-port=${String(rendererPort)}`, + `--user-data-dir=${userData}`, + APP_ROOT, + ], APP_ROOT, environment) +} + +async function main(): Promise { + const { values } = parseArgs({ options: { 'skip-build': { type: 'boolean', default: false } } }) + if (!values['skip-build']) { + await runPackageScript('build', REPOSITORY_ROOT) + await runPackageScript('build', APP_ROOT) + } + for (const path of [ + join(APP_ROOT, 'lib', 'main.js'), + join(REPOSITORY_ROOT, 'apps', 'desktop-host', 'lib', 'index.js'), + ]) { + if (!existsSync(path)) throw new Error(`desktop development: missing built artifact ${path}`) + } + const version = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const pnpmVersion = packageVersion(join(APP_ROOT, 'node_modules', 'pnpm', 'package.json'), 'pnpm package') + const release: DesktopRelease = { + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: process.versions.node, + pnpmVersion, + } + const projectDir = 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) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts new file mode 100644 index 0000000000..275373fffe --- /dev/null +++ b/apps/desktop/scripts/development-project.ts @@ -0,0 +1,120 @@ +/** Prepare the disposable npm-project view used by an unpackaged Electron shell. */ + +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, +} from 'node:fs' +import { dirname, join } from 'node:path' +import { createDevelopmentProjectMetadata } from '../src/project-manager.ts' +import type { DesktopRelease } from '../src/release.ts' + +interface PackageManifest { + readonly name?: string + readonly version?: string +} + +/** Inputs whose locations differ between the launcher and isolated tests. */ +export interface DevelopmentProjectOptions { + /** Directory replaced with the generated development project. */ + readonly projectDir: string + /** Current workspace's `apps/cli` package directory. */ + readonly cliDir: string + /** Current workspace's private Desktop Host application directory. */ + readonly hostDir: string + /** pnpm's workspace-wide virtual-hoist directory. */ + readonly dependencyDir: string + /** Release identity written into the disposable project metadata. */ + readonly release: DesktopRelease +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +function removeOwnedPath(path: string): void { + let stat: ReturnType + try { + stat = lstatSync(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (stat.isDirectory()) { + rmSync(path, { recursive: true }) + return + } + unlinkSync(path) +} + +function linkDirectory(source: string, destination: string): void { + mkdirSync(dirname(destination), { recursive: true }) + symlinkSync(realpathSync(source), destination, process.platform === 'win32' ? 'junction' : 'dir') +} + +function mirrorDependencyLinks(sourceRoot: string, destinationRoot: string): void { + for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) { + if (entry.name === '.bin') continue + const source = join(sourceRoot, entry.name) + if (entry.name.startsWith('@') && (entry.isDirectory() || entry.isSymbolicLink())) { + mkdirSync(join(destinationRoot, entry.name), { recursive: true }) + for (const scoped of readdirSync(source, { withFileTypes: true })) { + if (!scoped.isDirectory() && !scoped.isSymbolicLink()) continue + linkDirectory(join(source, scoped.name), join(destinationRoot, entry.name, scoped.name)) + } + continue + } + if (entry.isDirectory() || entry.isSymbolicLink()) linkDirectory(source, join(destinationRoot, entry.name)) + } +} + +/** + * Replace one disposable project with links to the current built workspace. + * @param options - Project destination, CLI package, and release identity. + * @returns the absolute project directory supplied by the caller. + */ +export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string { + const cliManifest = readManifest(join(options.cliDir, 'package.json')) + if (cliManifest.name !== '@deepseek-ai/dsh' || cliManifest.version !== options.release.version) { + throw new Error( + `desktop development: apps/cli must be @deepseek-ai/dsh@${options.release.version}, found ` + + `${String(cliManifest.name)}@${String(cliManifest.version)}`, + ) + } + if (!existsSync(options.dependencyDir)) { + throw new Error('desktop development: workspace dependency links are missing; run pnpm install') + } + const hostManifest = readManifest(join(options.hostDir, 'package.json')) + if (hostManifest.name !== '@deepseek-ai/dsh-desktop-host' || hostManifest.version !== options.release.version) { + throw new Error( + `desktop development: apps/desktop-host must be @deepseek-ai/dsh-desktop-host@${options.release.version}, found ` + + `${String(hostManifest.name)}@${String(hostManifest.version)}`, + ) + } + if (!existsSync(join(options.hostDir, 'lib', 'index.js'))) { + throw new Error('desktop development: apps/desktop-host/lib/index.js is missing; run pnpm run build') + } + + removeOwnedPath(options.projectDir) + createDevelopmentProjectMetadata(options.projectDir, options.release) + const destinationModules = join(options.projectDir, 'node_modules') + mkdirSync(destinationModules, { recursive: true }) + mirrorDependencyLinks(options.dependencyDir, destinationModules) + const dshLink = join(destinationModules, '@deepseek-ai', 'dsh') + removeOwnedPath(dshLink) + linkDirectory(options.cliDir, dshLink) + const hostLink = join(destinationModules, '@deepseek-ai', 'dsh-desktop-host') + removeOwnedPath(hostLink) + linkDirectory(options.hostDir, hostLink) + return options.projectDir +} diff --git a/apps/desktop/scripts/macos-seed-store.ts b/apps/desktop/scripts/macos-seed-store.ts new file mode 100644 index 0000000000..1a02dfd5cc --- /dev/null +++ b/apps/desktop/scripts/macos-seed-store.ts @@ -0,0 +1,413 @@ +/** 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/notarize-macos-disk-images.d.mts b/apps/desktop/scripts/notarize-macos-disk-images.d.mts new file mode 100644 index 0000000000..78a2b5bdd4 --- /dev/null +++ b/apps/desktop/scripts/notarize-macos-disk-images.d.mts @@ -0,0 +1,23 @@ +import type { NotarizeOptions } from '@electron/notarize' +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' + +/** Completed electron-builder artifact needed for disk-image notarization. */ +export interface DesktopBuildArtifact { + readonly file: string +} + +/** + * Submit one generated DMG to Apple, staple its ticket, and verify Gatekeeper acceptance. + * @param artifact - Completed electron-builder artifact. + * @param env - Packaging environment. + * @param expected - Public release identity. + * @param submit - Notary submission implementation. + * @param verify - Disk-image qualification implementation. + */ +export function notarizeMacOSDiskImageArtifact( + artifact: DesktopBuildArtifact, + env: NodeJS.ProcessEnv, + expected: MacOSSigningEnvironment, + submit?: (options: NotarizeOptions) => Promise, + verify?: (path: string, expected: MacOSSigningEnvironment) => void, +): Promise diff --git a/apps/desktop/scripts/notarize-macos-disk-images.mjs b/apps/desktop/scripts/notarize-macos-disk-images.mjs new file mode 100644 index 0000000000..de0a230a04 --- /dev/null +++ b/apps/desktop/scripts/notarize-macos-disk-images.mjs @@ -0,0 +1,30 @@ +/** Notarize and qualify macOS disk images after electron-builder creates them. */ + +import { notarize } from '@electron/notarize' +import { rmSync } from 'node:fs' +import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs' +import { verifyMacOSDiskImage } from './verify-macos-signature.mjs' + +/** + * Submit one generated DMG to Apple, staple its ticket, and verify Gatekeeper acceptance. + * @param {{ file: string }} artifact - Completed electron-builder artifact. + * @param {NodeJS.ProcessEnv} env - Packaging environment. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @param {(options: object) => Promise} submit - Notary submission implementation. + * @param {(path: string, expected: object) => void} verify - Disk-image qualification implementation. + * @returns {Promise} + */ +export async function notarizeMacOSDiskImageArtifact( + artifact, + env, + expected, + submit = notarize, + verify = verifyMacOSDiskImage, +) { + if (!artifact.file.endsWith('.dmg')) return + rmSync(`${artifact.file}.blockmap`, { force: true }) + const credentials = resolveMacOSNotarizationEnvironment(env) + await submit({ appPath: artifact.file, ...credentials }) + verify(artifact.file, expected) + process.stdout.write(`desktop macOS notarization: verified disk image ${artifact.file}\n`) +} diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts new file mode 100644 index 0000000000..1f32123ec0 --- /dev/null +++ b/apps/desktop/scripts/package-target.ts @@ -0,0 +1,285 @@ +/** Build one release target with matching Electron, Node.js, and seed architecture. */ + +import { spawn } from 'node:child_process' +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { parseArgs } from 'node:util' +import { join, resolve } from 'node:path' +import { + desktopBuildRecordFilename, + resolveDesktopAutoUpdateConfig, +} from './desktop-auto-update-environment.mjs' +import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') +const WINDOWS_SIGNING_ENV_PREFIX = 'DSH_DESKTOP_WINDOWS_' +const WINDOWS_SIGNING_ENV_NAMES = [ + 'DSH_DESKTOP_WINDOWS_CER_FILE', + 'DSH_DESKTOP_WINDOWS_KEY_CONTAINER', + 'DSH_DESKTOP_WINDOWS_SIGNTOOL', + 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', +] as const +const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ + 'DOWNLOAD_TEST_COS_SECRET_ID', + 'DOWNLOAD_TEST_COS_SECRET_KEY', + 'DOWNLOAD_PROD_COS_SECRET_ID', + 'DOWNLOAD_PROD_COS_SECRET_KEY', +]) + +/** Fixed platform and architecture identifiers exposed by package scripts. */ +export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64' + +/** One supported release target and its electron-builder selectors. */ +export interface DesktopPackageTarget { + readonly name: DesktopPackageTargetName + readonly platform: 'darwin' | 'win32' + readonly arch: 'arm64' | 'x64' + readonly builderPlatform: '--mac' | '--win' + readonly builderArch: '--arm64' | '--x64' +} + +const TARGETS: Record = { + 'mac-arm64': { + name: 'mac-arm64', + platform: 'darwin', + arch: 'arm64', + builderPlatform: '--mac', + builderArch: '--arm64', + }, + 'mac-x64': { + name: 'mac-x64', + platform: 'darwin', + arch: 'x64', + builderPlatform: '--mac', + builderArch: '--x64', + }, + 'win-x64': { + name: 'win-x64', + platform: 'win32', + arch: 'x64', + builderPlatform: '--win', + builderArch: '--x64', + }, +} + +/** + * Remove Windows signing configuration from package preparation subprocesses. + * @param environment - Packaging command environment. + * @returns A copy without Windows signing fields. + */ +export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX))) +} + +/** + * Remove upload-only COS credentials from every packaging subprocess. + * @param environment - Packaging command environment. + * @returns A copy without Desktop upload credentials. + */ +export function withoutDesktopUploadCredentials(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES.has(name))) +} + +function isTargetName(value: string): value is DesktopPackageTargetName { + return Object.hasOwn(TARGETS, value) +} + +function packageVersion(path: string, label: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string' || manifest.version === '') { + throw new Error(`desktop package: ${label} has no version`) + } + return manifest.version +} + +function writeReleaseRecord( + target: DesktopPackageTarget, + environment: NodeJS.ProcessEnv, + artifactsRoot: string, +): void { + const desktopVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const dshVersion = packageVersion(join(REPOSITORY_ROOT, 'package.json'), 'dsh package') + if (desktopVersion !== dshVersion) { + throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`) + } + const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) + const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name)) + const temporaryPath = `${recordPath}.tmp` + writeFileSync(temporaryPath, `${JSON.stringify({ + schemaVersion: 1, + target: target.name, + version: dshVersion, + environment: update.environment, + publicUrl: update.publicUrl, + }, null, 2)}\n`) + renameSync(temporaryPath, recordPath) +} + +/** + * Resolve a named release target and reject hosts that cannot execute its packaged runtime. + * @param name - One of the fixed Desktop release target names. + * @param hostPlatform - Build-host Node.js platform. + * @param hostArch - Build-host Node.js architecture. + * @returns The target selectors shared by runtime preparation and electron-builder. + */ +export function resolveDesktopPackageTarget( + name: string, + hostPlatform: NodeJS.Platform = process.platform, + hostArch: string = process.arch, +): DesktopPackageTarget { + if (!isTargetName(name)) { + throw new Error(`desktop package: unsupported target ${JSON.stringify(name)}; expected ${Object.keys(TARGETS).join(', ')}`) + } + const target = TARGETS[name] + if (target.platform === 'win32' && (hostPlatform !== 'win32' || hostArch !== 'x64')) { + throw new Error('desktop package: win-x64 requires a Windows x64 build host') + } + if (target.platform === 'darwin' && hostPlatform !== 'darwin') { + throw new Error(`desktop package: ${name} requires a macOS build host`) + } + if (name === 'mac-arm64' && hostArch !== 'arm64') { + throw new Error('desktop package: mac-arm64 requires an Apple Silicon build host') + } + if (name === 'mac-x64' && hostArch !== 'arm64' && hostArch !== 'x64') { + throw new Error('desktop package: mac-x64 requires an Intel Mac or Apple Silicon with Rosetta') + } + return target +} + +interface DesktopPackageInvocation { + readonly target: DesktopPackageTarget + readonly directory: boolean + readonly prepareOnly: boolean +} + +function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { + const name = `${platform === 'darwin' ? 'mac' : platform === 'win32' ? 'win' : platform}-${arch}` + if (!isTargetName(name)) throw new Error(`desktop package: unsupported build host ${platform}-${arch}`) + return name +} + +/** + * Parse the fixed-target packaging command line. + * @param argv - Arguments after the script entry point. + * @param hostPlatform - Build-host Node.js platform. + * @param hostArch - Build-host Node.js architecture. + * @returns The validated target and whether to emit an unpacked directory. + */ +export function parseDesktopPackageInvocation( + argv: readonly string[], + hostPlatform: NodeJS.Platform = process.platform, + hostArch: string = process.arch, +): DesktopPackageInvocation { + const { values, positionals } = parseArgs({ + args: [...argv], + allowPositionals: true, + options: { + dir: { type: 'boolean', default: false }, + 'prepare-only': { 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) + return { + target: resolveDesktopPackageTarget(name, hostPlatform, hostArch), + directory: values.dir, + prepareOnly: values['prepare-only'], + } +} + +/** + * Build the electron-builder command arguments for one validated target. + * @param target - Supported release target. + * @param directory - Whether to stop at an unpacked application directory. + * @returns Arguments that keep publishing under the separate validated upload command. + */ +export function desktopElectronBuilderArguments( + target: DesktopPackageTarget, + directory: boolean, +): readonly string[] { + return [ + 'exec', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + target.builderPlatform, + target.builderArch, + '--publish', + 'never', + ...(directory ? ['--dir'] : []), + ] +} + +function runPnpm( + args: readonly string[], + env: NodeJS.ProcessEnv = process.env, + cwd: string = APP_ROOT, +): Promise { + const pnpmEntry = process.env.npm_execpath + if (pnpmEntry === undefined || pnpmEntry === '') { + throw new Error('desktop package: invoke this script through a pnpm package command') + } + return new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [pnpmEntry, ...args], { + cwd, + env, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function main(): Promise { + const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) + const { target } = invocation + const buildPaths = desktopTargetBuildPaths(target.name) + const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) + if (!invocation.prepareOnly) { + rmSync(releaseRecordPath, { force: true }) + rmSync(`${releaseRecordPath}.tmp`, { force: true }) + } + const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials(process.env)) + const targetEnv: NodeJS.ProcessEnv = { + ...buildEnv, + DSH_DESKTOP_TARGET_PLATFORM: target.platform, + DSH_DESKTOP_TARGET_ARCH: target.arch, + } + const electronBuilderEnv = { ...targetEnv } + for (const name of WINDOWS_SIGNING_ENV_NAMES) { + if (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) + await runPnpm([ + '--dir', + 'apps/desktop-host', + 'pack', + '--pack-destination', + buildPaths.packedDsh, + ], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor], buildEnv, REPOSITORY_ROOT) + rmSync(buildPaths.packedLandlock, { recursive: true, force: true }) + mkdirSync(buildPaths.packedLandlock, { recursive: true }) + await runPnpm(['--dir', 'native/landlock-run', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT) + await runPnpm([ + '--dir', + 'native/landlock-run/packages/entry', + 'pack', + '--pack-destination', + buildPaths.packedLandlock, + ], buildEnv, REPOSITORY_ROOT) + await runPnpm(['run', 'prepare:runtime'], targetEnv) + await runPnpm(['run', 'prepare:packages'], targetEnv) + await runPnpm(['run', 'prepare:seed'], targetEnv) + if (invocation.prepareOnly) return + await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) + if (!invocation.directory) 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-package-set.ts b/apps/desktop/scripts/prepare-package-set.ts new file mode 100644 index 0000000000..f67d767e81 --- /dev/null +++ b/apps/desktop/scripts/prepare-package-set.ts @@ -0,0 +1,172 @@ +/** Select and copy the local npm tarball closures that supply Desktop dsh and its private Host. */ + +import { createHash } from 'node:crypto' +import { + constants, + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { + DESKTOP_HOST_PACKAGE, + DESKTOP_HOST_RUNTIME_FILES, + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + parseDesktopCorePackageSet, + type DesktopCorePackageRecord, +} from '../src/core-package-set.ts' +import { capture } from '../../../scripts/release/process.ts' +import { tarballFiles } from '../../../scripts/release/tarball.ts' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const DSH_PACKAGE = '@deepseek-ai/dsh' +const ROOT_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const +const APP_ROOT = resolve(import.meta.dirname, '..') +const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') + +const REQUIRED_DEPENDENCY_SECTIONS = ['dependencies', 'peerDependencies'] as const +const OPTIONAL_DEPENDENCY_SECTION = 'optionalDependencies' + +/** Packed package information needed to form the local Desktop closure. */ +export interface PackedDesktopPackage { + readonly tarball: string + readonly manifest: Readonly> +} + +function dependencyNames(manifest: Readonly>, section: string): string[] { + const value = manifest[section] + if (value === undefined) return [] + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`desktop package set: ${String(manifest.name)} has invalid ${section}`) + } + return Object.keys(value).sort() +} + +/** + * Select the complete available first-party dependency closures rooted at dsh and its private Host. + * @param available - Packed packages indexed by package name. + * @returns Selected packages sorted by name. + */ +export function selectDesktopPackageClosure( + available: ReadonlyMap, +): PackedDesktopPackage[] { + const selected = new Map() + const visit = (name: string): void => { + if (selected.has(name)) return + const packed = available.get(name) + if (packed === undefined) throw new Error(`desktop package set: packed inputs omit required package ${name}`) + selected.set(name, packed) + for (const section of REQUIRED_DEPENDENCY_SECTIONS) { + for (const dependency of dependencyNames(packed.manifest, section)) { + if (available.has(dependency)) visit(dependency) + else if (dependency.startsWith('@deepseek-ai/')) { + throw new Error(`desktop package set: ${name} requires unpacked internal package ${dependency}`) + } + } + } + for (const dependency of dependencyNames(packed.manifest, OPTIONAL_DEPENDENCY_SECTION)) { + if (available.has(dependency)) visit(dependency) + } + } + for (const name of ROOT_PACKAGES) { + if (!available.has(name)) throw new Error(`desktop package set: packed inputs omit ${name}`) + visit(name) + } + return [...selected.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([, packed]) => packed) +} + +function packedManifest(tarball: string): Record { + const value: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json'])) + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`desktop package set: ${tarball} has no package manifest`) + } + return value as Record +} + +function packedPackages(inputs: readonly string[]): Map { + const available = new Map() + for (const input of inputs) { + const tarballs = readdirSync(input).filter(file => file.endsWith('.tgz')).sort() + if (tarballs.length === 0) throw new Error(`desktop package set: ${input} contains no tarballs`) + for (const file of tarballs) { + const tarball = join(input, file) + const manifest = packedManifest(tarball) + const name = manifest.name + if (typeof name !== 'string' || name === '') throw new Error(`desktop package set: ${tarball} has no package name`) + if (available.has(name)) throw new Error(`desktop package set: duplicate packed package ${name}`) + available.set(name, { tarball, manifest }) + } + } + return available +} + +/** + * Require every private Host file used before the Desktop profile can pass its health check. + * @param files - Tarball paths rooted at `package/`. + * @returns Nothing. + */ +export function assertDesktopHostPackageFiles(files: readonly string[]): void { + const available = new Set(files) + const missing = DESKTOP_HOST_RUNTIME_FILES + .map(file => `package/${file}`) + .filter(file => !available.has(file)) + if (missing.length > 0) { + throw new Error(`desktop package set: ${DESKTOP_HOST_PACKAGE} tarball omits required file(s): ${missing.join(', ')}`) + } +} + +/** Prepare a package set from release tarball directories. */ +export function prepareDesktopPackageSet(inputs: readonly string[], output: string): void { + const selected = selectDesktopPackageClosure(packedPackages(inputs)) + const host = selected.find(packed => packed.manifest.name === DESKTOP_HOST_PACKAGE) + if (host === undefined) throw new Error(`desktop package set: selected closure omits ${DESKTOP_HOST_PACKAGE}`) + assertDesktopHostPackageFiles(tarballFiles(host.tarball)) + rmSync(output, { recursive: true, force: true }) + const packageDir = join(output, DESKTOP_PACKAGES_DIR) + mkdirSync(packageDir, { recursive: true }) + const records: DesktopCorePackageRecord[] = selected.map((packed) => { + const name = packed.manifest.name + const version = packed.manifest.version + if (typeof name !== 'string' || typeof version !== 'string') { + throw new Error(`desktop package set: ${packed.tarball} has no package identity`) + } + const file = basename(packed.tarball) + const destination = join(packageDir, file) + copyFileSync(packed.tarball, destination, constants.COPYFILE_EXCL) + const body = readFileSync(destination) + return { + name, + version, + file, + bytes: statSync(destination).size, + integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, + } + }) + const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: records }) + writeFileSync(join(output, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify(packageSet, undefined, 2)}\n`, { mode: 0o600 }) +} + +function main(): void { + const buildPaths = resolveDesktopTargetBuildPaths() + const defaultInputs = [ + buildPaths.packedDsh, + buildPaths.packedVendor, + buildPaths.packedLandlock, + ] + const { values } = parseArgs({ + options: { from: { type: 'string', multiple: true }, out: { type: 'string' } }, + allowPositionals: false, + }) + const inputs = (values.from ?? defaultInputs).map(path => resolve(REPOSITORY_ROOT, path)) + const output = values.out === undefined ? buildPaths.packageSet : resolve(REPOSITORY_ROOT, values.out) + prepareDesktopPackageSet(inputs, output) + console.log(`desktop package set: prepared ${output}`) +} + +if (import.meta.main) main() diff --git a/apps/desktop/scripts/prepare-runtime.ts b/apps/desktop/scripts/prepare-runtime.ts new file mode 100644 index 0000000000..1b22293c23 --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime.ts @@ -0,0 +1,107 @@ +/** Download and verify the upstream Node.js runtime and copy the pinned pnpm CLI. */ + +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmod, readFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import extractZip from 'extract-zip' +import { extract } from 'tar' +import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const NODE_VERSION = '24.17.0' +const BUILD_PATHS = resolveDesktopTargetBuildPaths() +const RUNTIME_ROOT = BUILD_PATHS.runtime +const DOWNLOAD_ROOT = BUILD_PATHS.downloads + +type RuntimePlatform = 'darwin' | 'linux' | 'win' +type RuntimeArch = 'arm64' | 'x64' + +function target(): { platform: RuntimePlatform; arch: RuntimeArch } { + const rawPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.env.npm_config_platform ?? process.platform + const rawArch = process.env.DSH_DESKTOP_TARGET_ARCH ?? process.env.npm_config_arch ?? process.arch + const platform = rawPlatform === 'win32' ? 'win' : rawPlatform + if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win') { + throw new Error(`desktop runtime: unsupported platform ${rawPlatform}`) + } + if (rawArch !== 'arm64' && rawArch !== 'x64') throw new Error(`desktop runtime: unsupported architecture ${rawArch}`) + return { platform, arch: rawArch } +} + +async function download(url: string, path: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error(`desktop runtime: ${url} returned HTTP ${String(response.status)}`) + writeFileSync(path, new Uint8Array(await response.arrayBuffer()), { mode: 0o600 }) +} + +async function prepareNode(platform: RuntimePlatform, arch: RuntimeArch): Promise { + const extension = platform === 'win' ? 'zip' : 'tar.gz' + const folder = `node-v${NODE_VERSION}-${platform}-${arch}` + const archiveName = `${folder}.${extension}` + const releaseRoot = `https://nodejs.org/download/release/v${NODE_VERSION}` + const archive = join(DOWNLOAD_ROOT, archiveName) + const sums = join(DOWNLOAD_ROOT, `node-v${NODE_VERSION}-SHASUMS256.txt`) + if (!existsSync(archive)) await download(`${releaseRoot}/${archiveName}`, archive) + if (!existsSync(sums)) await download(`${releaseRoot}/SHASUMS256.txt`, sums) + const line = (await readFile(sums, 'utf8')).split(/\r?\n/u) + .find(candidate => candidate.endsWith(` ${archiveName}`)) + if (line === undefined) throw new Error(`desktop runtime: ${archiveName} is absent from Node.js SHASUMS256.txt`) + const expected = line.split(/\s+/u)[0] + const actual = createHash('sha256').update(await readFile(archive)).digest('hex') + if (actual !== expected) throw new Error(`desktop runtime: checksum mismatch for ${archiveName}`) + + const extraction = BUILD_PATHS.nodeExtract + rmSync(extraction, { recursive: true, force: true }) + mkdirSync(extraction, { recursive: true }) + if (platform === 'win') await extractZip(archive, { dir: extraction }) + else await extract({ cwd: extraction, file: archive }) + const source = join(extraction, folder, platform === 'win' ? 'node.exe' : 'bin/node') + const destinationRoot = join(RUNTIME_ROOT, 'node') + const destination = join(destinationRoot, platform === 'win' ? 'node.exe' : 'node') + rmSync(destinationRoot, { recursive: true, force: true }) + mkdirSync(destinationRoot, { recursive: true }) + // A fresh write prevents macOS from retaining invalid code-signature vnode state from a tar-extracted Mach-O clone. + await pipeline(createReadStream(source), createWriteStream(destination, { flags: 'wx' })) + if (platform !== 'win') await chmod(destination, 0o755) + const hostPlatform = process.platform === 'win32' ? 'win' : process.platform + const hostCanExecute = platform === hostPlatform + && (arch === process.arch || (platform === 'darwin' && arch === 'x64' && process.arch === 'arm64')) + if (hostCanExecute) { + const result = spawnSync(destination, ['--version'], { encoding: 'utf8' }) + if (result.error !== undefined || result.status !== 0 || result.stdout.trim() !== `v${NODE_VERSION}`) { + const detail = result.error?.message ?? result.signal ?? result.stderr.trim() + const outcome = detail === '' ? `exit ${String(result.status)}` : detail + throw new Error(`desktop runtime: prepared Node.js ${NODE_VERSION} failed executable verification: ${outcome}`) + } + } + rmSync(extraction, { recursive: true, force: true }) +} + +function preparePnpm(): string { + const require = createRequire(import.meta.url) + const manifestPath = require.resolve('pnpm') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error('desktop runtime: pnpm manifest has no version') + const packageDir = dirname(manifestPath) + const destination = join(RUNTIME_ROOT, 'pnpm') + rmSync(destination, { recursive: true, force: true }) + cpSync(packageDir, destination, { recursive: true }) + return manifest.version +} + +async function main(): Promise { + const { platform, arch } = target() + mkdirSync(DOWNLOAD_ROOT, { recursive: true }) + mkdirSync(RUNTIME_ROOT, { recursive: true }) + await prepareNode(platform, arch) + const pnpmVersion = preparePnpm() + writeFileSync(join(RUNTIME_ROOT, 'versions.json'), `${JSON.stringify({ + schemaVersion: 1, + node: NODE_VERSION, + pnpm: pnpmVersion, + }, undefined, 2)}\n`) +} + +await main() diff --git a/apps/desktop/scripts/prepare-seed.ts b/apps/desktop/scripts/prepare-seed.ts new file mode 100644 index 0000000000..73da078ef2 --- /dev/null +++ b/apps/desktop/scripts/prepare-seed.ts @@ -0,0 +1,200 @@ +/** 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/upload-target.ts b/apps/desktop/scripts/upload-target.ts new file mode 100644 index 0000000000..f2d436499d --- /dev/null +++ b/apps/desktop/scripts/upload-target.ts @@ -0,0 +1,83 @@ +/** Upload one validated Desktop release to its Tencent COS update directory. */ + +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import type { DesktopPackageTargetName } from './package-target.ts' +import { + createDesktopUploadPlan, + type DesktopUploadArtifact, +} from './desktop-upload-plan.ts' + +const SUPPORTED_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) + +function targetName(value: string): DesktopPackageTargetName { + if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) { + throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) + } + return value as DesktopPackageTargetName +} + +function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string { + const value = environment[name]?.trim() + if (value === undefined || value === '') { + throw new Error(`desktop upload: ${name} must be set to a non-empty value`) + } + return value +} + +async function putArtifact( + client: S3Client, + bucket: string, + artifact: DesktopUploadArtifact, +): Promise { + const details = await stat(artifact.path) + const body = createReadStream(artifact.path) + try { + await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: artifact.key, + Body: body, + ContentLength: details.size, + ContentType: artifact.contentType, + CacheControl: artifact.cacheControl, + })) + } + finally { + body.destroy() + } + process.stdout.write(`desktop upload: uploaded ${artifact.key}\n`) +} + +async function main(): Promise { + const { positionals } = parseArgs({ args: process.argv.slice(2), allowPositionals: true }) + const target = positionals[0] + if (target === undefined || positionals.length !== 1) { + throw new Error('desktop upload: expected exactly one target') + } + const plan = await createDesktopUploadPlan(targetName(target)) + const client = new S3Client({ + region: 'Auto', + endpoint: 'https://cos.ap-beijing.myqcloud.com', + credentials: { + accessKeyId: requiredEnvironmentValue(process.env, plan.secretIdEnvName), + secretAccessKey: requiredEnvironmentValue(process.env, plan.secretKeyEnvName), + }, + }) + process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) + try { + for (const artifact of plan.artifacts) await putArtifact(client, plan.bucket, artifact) + } + finally { + client.destroy() + } +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts new file mode 100644 index 0000000000..b408e1bd54 --- /dev/null +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -0,0 +1,73 @@ +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' + +/** + * Reject signature metadata that does not name the company release authority and team. + * @param details - Output from `codesign --display --verbose=4`. + * @param expected - Public release identity. + */ +export function assertMacOSSignatureDetails(details: string, expected: MacOSSigningEnvironment): void + +/** + * Require the signature properties Apple validates for executable seed content. + * @param details - Output from `codesign --display --verbose=4`. + * @param expected - Public release identity. + */ +export function assertMacOSSeedSignatureDetails(details: string, expected: MacOSSigningEnvironment): void + +/** + * Sign one Mach-O file embedded in the seed store. + * @param path - Writable standalone Mach-O file. + * @param identifier - Stable code-signing identifier derived from the release app ID and CAS digest. + * @param expected - Public release identity. + * @returns Resolves after codesign exits successfully. + */ +export function signMacOSSeedCode( + path: string, + identifier: string, + expected: MacOSSigningEnvironment, +): Promise + +/** + * Verify one Mach-O file embedded in the seed store. + * @param path - Mach-O file to inspect. + * @param expected - Public release identity. + */ +export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void + +/** + * Verify the full application signature and its release owner. + * @param appPath - Path to the packaged `.app` directory. + * @param expected - Public release identity. + */ +export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void + +/** + * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. + * @param diskImagePath - Path to the packaged `.dmg` file. + * @param expected - Public release identity. + */ +export function verifyMacOSDiskImage( + diskImagePath: string, + expected: MacOSSigningEnvironment, +): void + +/** Electron-builder fields required to locate a signed macOS application. */ +export interface MacOSAfterSignContext { + readonly electronPlatformName: string + readonly appOutDir: string + readonly packager: { + readonly appInfo: { + readonly productFilename: string + } + } +} + +/** + * Verify the macOS application produced by electron-builder's signing phase. + * @param context - electron-builder hook context. + * @param expected - Public release identity. + */ +export function verifyMacOSSignatureAfterSign( + context: MacOSAfterSignContext, + expected: MacOSSigningEnvironment, +): void diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs new file mode 100644 index 0000000000..48b254dedd --- /dev/null +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -0,0 +1,186 @@ +/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */ + +import { spawn, spawnSync } from 'node:child_process' +import { resolve } from 'node:path' +import { resolveMacOSSigningEnvironment } from './desktop-release-environment.mjs' + +/** + * Reject signature metadata that does not name the company release authority and team. + * @param {string} details - Output from `codesign --display --verbose=4`. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function assertMacOSSignatureDetails(details, expected) { + const fields = new Set(details.split(/\r?\n/u).map(line => line.trim())) + const expectedAuthority = `Authority=Developer ID Application: ${expected.signingIdentity}` + const expectedTeam = `TeamIdentifier=${expected.teamId}` + const missing = [expectedAuthority, expectedTeam].filter(field => !fields.has(field)) + if (missing.length > 0) { + throw new Error(`desktop macOS signing: signature does not match the release identity; missing ${missing.join(', ')}`) + } +} + +/** + * Require the signature properties Apple validates for executable seed 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) { + 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') + } + 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') + } +} + +/** + * Execute one Apple release tool and return its diagnostic streams. + * @param {string} command - Absolute executable path. + * @param {readonly string[]} args - Tool arguments. + * @param {string} label - Stable diagnostic name. + * @returns {string} Combined stdout and stderr. + */ +function runAppleCommand(command, args, label) { + const result = spawnSync(command, args, { encoding: 'utf8' }) + if (result.error !== undefined) { + throw new Error(`desktop macOS signing: could not execute ${label}: ${result.error.message}`) + } + if (result.signal !== null) { + throw new Error(`desktop macOS signing: ${label} was terminated by ${result.signal}`) + } + if (result.status !== 0) { + const diagnostic = `${result.stdout}${result.stderr}`.trim() + throw new Error(`desktop macOS signing: ${label} exited with ${String(result.status)}${diagnostic === '' ? '' : `: ${diagnostic}`}`) + } + return `${result.stdout}${result.stderr}` +} + +/** + * Execute one Apple release tool without blocking other independent seed signers. + * @param {string} command - Absolute executable path. + * @param {readonly string[]} args - Tool arguments. + * @param {string} label - Stable diagnostic name. + * @returns {Promise} Combined stdout and stderr after process exit. + */ +function runAppleCommandAsync(command, args, label) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + let spawnError + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stderr += chunk }) + child.once('error', error => { spawnError = error }) + child.once('close', (code, signal) => { + if (spawnError !== undefined) { + reject(new Error(`desktop macOS signing: could not execute ${label}: ${spawnError.message}`)) + return + } + if (signal !== null) { + reject(new Error(`desktop macOS signing: ${label} was terminated by ${signal}`)) + return + } + if (code !== 0) { + const diagnostic = `${stdout}${stderr}`.trim() + reject(new Error(`desktop macOS signing: ${label} exited with ${String(code)}${diagnostic === '' ? '' : `: ${diagnostic}`}`)) + return + } + resolvePromise(`${stdout}${stderr}`) + }) + }) +} + +/** + * Execute Apple's code-signing tool and return its diagnostic streams. + * @param {readonly string[]} args - Arguments passed to `/usr/bin/codesign`. + * @returns {string} Combined stdout and stderr. + */ +function runCodeSign(args) { + return runAppleCommand('/usr/bin/codesign', args, 'codesign') +} + +/** + * Sign one Mach-O file embedded in the seed store. + * @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) { + await runAppleCommandAsync('/usr/bin/codesign', [ + '--force', + '--sign', expected.signingIdentity, + '--identifier', identifier, + '--timestamp', + '--options', 'runtime', + path, + ], 'codesign') +} + +/** + * Verify one Mach-O file embedded in the seed store. + * @param {string} path - Mach-O file to inspect. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSSeedCode(path, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', path]) + const details = runCodeSign(['--display', '--verbose=4', path]) + assertMacOSSeedSignatureDetails(details, expected) +} + +/** + * Verify the full application signature and its release owner. + * @param {string} appPath - Path to the packaged `.app` directory. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSSignature(appPath, expected) { + runCodeSign(['--verify', '--deep', '--strict', '--verbose=2', appPath]) + const details = runCodeSign(['--display', '--verbose=4', appPath]) + assertMacOSSignatureDetails(details, expected) +} + +/** + * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. + * @param {string} diskImagePath - Path to the packaged `.dmg` file. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSDiskImage(diskImagePath, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) + const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) + assertMacOSSignatureDetails(details, expected) + runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate') + runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl') +} + +/** + * Verify the macOS application produced by electron-builder's signing phase. + * @param {{ electronPlatformName: string, appOutDir: string, packager: { appInfo: { productFilename: string } } }} context - electron-builder hook context. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSSignatureAfterSign(context, expected) { + if (context.electronPlatformName !== 'darwin') return + const appPath = resolve(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + verifyMacOSSignature(appPath, expected) + process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + const cliArgs = process.argv[2] === '--' ? process.argv.slice(3) : process.argv.slice(2) + const appPath = cliArgs[0] + if (appPath === undefined || cliArgs.length !== 1) { + throw new Error('usage: node scripts/verify-macos-signature.mjs ') + } + const expected = resolveMacOSSigningEnvironment(process.env) + verifyMacOSSignature(resolve(appPath), expected) + process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) +} diff --git a/apps/desktop/scripts/windows-sign.cmd b/apps/desktop/scripts/windows-sign.cmd new file mode 100644 index 0000000000..366f787f57 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.cmd @@ -0,0 +1,17 @@ +@echo off +setlocal DisableDelayedExpansion +set "signTool=%DSH_DESKTOP_WINDOWS_SIGNTOOL%" +set "certificateFile=%DSH_DESKTOP_WINDOWS_CER_FILE%" +set "tokenPin=%DSH_DESKTOP_WINDOWS_TOKEN_PIN%" +set "keyContainer=%DSH_DESKTOP_WINDOWS_KEY_CONTAINER%" +set "targetFile=%DSH_DESKTOP_WINDOWS_SIGN_TARGET%" +set "appendSignature=" +if "%DSH_DESKTOP_WINDOWS_SIGN_APPEND%"=="1" set "appendSignature=/as" +set "DSH_DESKTOP_WINDOWS_SIGNTOOL=" +set "DSH_DESKTOP_WINDOWS_CER_FILE=" +set "DSH_DESKTOP_WINDOWS_TOKEN_PIN=" +set "DSH_DESKTOP_WINDOWS_KEY_CONTAINER=" +set "DSH_DESKTOP_WINDOWS_SIGN_TARGET=" +set "DSH_DESKTOP_WINDOWS_SIGN_APPEND=" +set "signTool=" & set "certificateFile=" & set "tokenPin=" & set "keyContainer=" & set "targetFile=" & set "appendSignature=" & "%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%" +exit /b %errorlevel% diff --git a/apps/desktop/scripts/windows-sign.d.mts b/apps/desktop/scripts/windows-sign.d.mts new file mode 100644 index 0000000000..4532f32099 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.d.mts @@ -0,0 +1,91 @@ +/** + * Build the minimal CMD environment for one Electron artifact. + * + * @param environment Parent environment. + * @param input Validated signing identity and task. + * @returns Scrubbed environment plus the fields consumed and cleared by the signing CMD. + */ +export function buildWindowsSigningEnvironment(environment: NodeJS.ProcessEnv, input: { + certificateFile: string + signTool: string + path: string + isNest: boolean + tokenPin: string + keyContainer: string +}): NodeJS.ProcessEnv + +/** + * Create the electron-builder hook for a hardware-backed Windows code-signing certificate. + * + * @param options Release signing configuration. + * @returns The signing hook. + */ +export function createWindowsTokenSigner(options: { + certificateFile?: string | undefined + signTool?: string | undefined + tokenPin?: string | undefined + keyContainer?: string | undefined + commandInterpreter?: string | undefined +}): ( + configuration: { + path: string + hash: string + isNest: boolean + }, +) => Promise + +/** + * Remove inherited credentials before starting a signing-related subprocess. + * + * @param environment Parent environment. + * @returns Environment without credential-shaped names. + */ +export function scrubWindowsSigningEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv + +/** + * Replace a SignTool failure with a diagnostic that cannot retain its command line. + * + * @param error SignTool process failure. + * @param path Artifact that failed signing. + * @param secrets Values that must not appear in the diagnostic. + * @returns Sanitized signing failure without the original error as its cause. + */ +export function createRedactedWindowsSigningError( + error: unknown, + path: string, + secrets: readonly string[], +): Error + +/** + * Clear a certificate-table entry that points beyond the end of a generated executable. + * + * @param path Executable to inspect. + * @returns Whether an invalid certificate-table entry was cleared. + */ +export function repairDanglingAuthenticodeDirectory(path: string): Promise + +/** + * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it. + * + * @param options Signing hook and injectable host values. + * @returns Nothing. + */ +export function installWindowsNsisBootstrapSigner(options: { + sign: (configuration: { + path: string + hash: string + isNest: boolean + }) => Promise + wineVmManager?: { + prototype: { + exec: ( + file: string, + args: string[], + options?: { env?: NodeJS.ProcessEnv }, + isLogOutIfDebug?: boolean, + ) => unknown + } + } + platform?: NodeJS.Platform + environment?: NodeJS.ProcessEnv +}): void diff --git a/apps/desktop/scripts/windows-sign.mjs b/apps/desktop/scripts/windows-sign.mjs new file mode 100644 index 0000000000..ac38a35601 --- /dev/null +++ b/apps/desktop/scripts/windows-sign.mjs @@ -0,0 +1,266 @@ +import { execFile } from 'node:child_process' +import { X509Certificate } from 'node:crypto' +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { open } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import wineVmModule from 'app-builder-lib/out/vm/WineVm.js' + +const execFileAsync = promisify(execFile) +const { WineVmManager } = wineVmModule +const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3' +const NSIS_RUN_AS_INVOKER = 'RunAsInvoker' +const NSIS_BOOTSTRAP_PATCH = Symbol.for('@deepseek-ai/dsh-desktop/nsis-bootstrap-signing') +const WINDOWS_SIGN_SCRIPT = 'windows-sign.cmd' +const WINDOWS_SIGN_SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url)) +const PE_HEADER_READ_SIZE = 4096 +const PE32_MAGIC = 0x10B +const PE32_PLUS_MAGIC = 0x20B +const SENSITIVE_ENVIRONMENT_NAME = /(?:KEY|SECRET|TOKEN|PASSWORD)/iu +const WINDOWS_SIGNING_ENVIRONMENT_PREFIX = 'DSH_DESKTOP_WINDOWS_' + +/** + * Remove inherited credentials before starting a signing-related subprocess. + * + * @param {NodeJS.ProcessEnv} environment Parent environment. + * @returns {NodeJS.ProcessEnv} Environment without credential-shaped names. + */ +export function scrubWindowsSigningEnvironment(environment) { + return Object.fromEntries(Object.entries(environment) + .filter(([name]) => !SENSITIVE_ENVIRONMENT_NAME.test(name) + && !name.startsWith(WINDOWS_SIGNING_ENVIRONMENT_PREFIX))) +} + +function resolveTokenIdentity(input) { + const keyContainer = input.keyContainer?.trim() + if (!keyContainer) { + throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER must contain the SafeNet private-key container name') + } + if (/["\r\n]/u.test(keyContainer)) { + throw new Error('DSH_DESKTOP_WINDOWS_KEY_CONTAINER cannot contain quotes or line breaks') + } + const tokenPin = input.tokenPin + if (tokenPin === undefined || tokenPin.length === 0) { + throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN must contain the SafeNet Token Password') + } + if (/[\]"\r\n]/u.test(tokenPin)) { + throw new Error('DSH_DESKTOP_WINDOWS_TOKEN_PIN cannot contain "]", quotes, or line breaks because the SafeNet key-container syntax uses them as delimiters') + } + return { keyContainer, tokenPin } +} + +function resolveCertificateFile(value) { + const candidate = value?.trim() + if (!candidate) { + throw new Error('DSH_DESKTOP_WINDOWS_CER_FILE must identify the public X.509 leaf certificate file') + } + let path + let certificate + try { + path = realpathSync(candidate) + certificate = new X509Certificate(readFileSync(path)) + } + catch { + throw new Error(`Windows code-signing certificate file is missing or invalid: ${candidate}`) + } + if (certificate.ca || !certificate.keyUsage?.includes(CODE_SIGNING_EKU)) { + throw new Error(`Windows code-signing certificate file must contain a non-CA Code Signing certificate: ${path}`) + } + return path +} + +function resolveSignTool(value) { + const candidate = value?.trim() + if (!candidate) { + throw new Error('DSH_DESKTOP_WINDOWS_SIGNTOOL must identify the SafeNet-compatible SignTool executable') + } + let path + try { + path = realpathSync(candidate) + if (!statSync(path).isFile() || !path.toLowerCase().endsWith('.exe')) throw new Error('not an executable file') + } + catch { + throw new Error(`DSH_DESKTOP_WINDOWS_SIGNTOOL is missing or is not an executable file: ${candidate}`) + } + return path +} + +function redactedSigningOutput(value, secrets) { + let output = Buffer.isBuffer(value) ? value.toString('utf8') : typeof value === 'string' ? value : '' + for (const secret of secrets) { + if (secret !== '') output = output.replaceAll(secret, '') + } + return output +} + +/** + * Replace a SignTool failure with a diagnostic that cannot retain its command line. + * + * @param {unknown} error SignTool process failure. + * @param {string} path Artifact that failed signing. + * @param {readonly string[]} secrets Values that must not appear in the diagnostic. + * @returns {Error} Sanitized signing failure without the original error as its cause. + */ +export function createRedactedWindowsSigningError(error, path, secrets) { + const record = error !== null && typeof error === 'object' ? error : undefined + const code = record !== undefined && 'code' in record + && (typeof record.code === 'number' || typeof record.code === 'string') + ? ` (exit ${String(record.code)})` + : '' + const stderr = record !== undefined && 'stderr' in record + ? redactedSigningOutput(record.stderr, secrets).trim() + : '' + return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`) +} + +/** + * Build the minimal CMD environment for one Electron artifact. + * + * @param {NodeJS.ProcessEnv} environment Parent environment. + * @param {{ certificateFile: string, signTool: string, path: string, isNest: boolean, tokenPin: string, keyContainer: string }} input Validated signing identity and task. + * @returns {NodeJS.ProcessEnv} Scrubbed environment plus fields consumed and cleared by the signing CMD. + */ +export function buildWindowsSigningEnvironment(environment, input) { + return { + ...scrubWindowsSigningEnvironment(environment), + DSH_DESKTOP_WINDOWS_SIGNTOOL: input.signTool, + DSH_DESKTOP_WINDOWS_CER_FILE: input.certificateFile, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: input.tokenPin, + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: input.keyContainer, + DSH_DESKTOP_WINDOWS_SIGN_TARGET: input.path, + DSH_DESKTOP_WINDOWS_SIGN_APPEND: input.isNest ? '1' : '', + } +} + +/** + * Create the electron-builder hook for a SafeNet-backed Windows code-signing certificate. + * + * @param {{ certificateFile?: string, signTool?: string, tokenPin?: string, keyContainer?: string, commandInterpreter?: string }} options Release signing configuration. + * @returns {(configuration: { path: string, hash: string, isNest: boolean }) => Promise} The signing hook. + */ +export function createWindowsTokenSigner(options) { + const certificateFile = resolveCertificateFile(options.certificateFile) + const signTool = resolveSignTool(options.signTool) + const { keyContainer, tokenPin } = resolveTokenIdentity(options) + const commandInterpreter = options.commandInterpreter + ?? process.env.ComSpec + ?? join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + return async (configuration) => { + if (configuration.hash !== 'sha256') { + throw new Error(`Windows release signing requires SHA-256, received ${configuration.hash}`) + } + await repairDanglingAuthenticodeDirectory(configuration.path) + const secrets = [tokenPin] + let result + try { + result = await execFileAsync(commandInterpreter, [ + '/d', + '/v:off', + '/c', + WINDOWS_SIGN_SCRIPT, + ], { + cwd: WINDOWS_SIGN_SCRIPT_DIRECTORY, + env: buildWindowsSigningEnvironment(process.env, { + certificateFile, + signTool, + path: configuration.path, + isNest: configuration.isNest, + tokenPin, + keyContainer, + }), + windowsHide: false, + }) + } + catch (error) { + throw createRedactedWindowsSigningError(error, configuration.path, secrets) + } + const stdout = redactedSigningOutput(result.stdout, secrets) + const stderr = redactedSigningOutput(result.stderr, secrets) + if (stdout !== '') process.stdout.write(stdout) + if (stderr !== '') process.stderr.write(stderr) + } +} + +/** + * Clear a certificate-table entry that points beyond the end of a generated executable. + * + * @param {string} path Executable to inspect. + * @returns {Promise} Whether an invalid certificate-table entry was cleared. + */ +export async function repairDanglingAuthenticodeDirectory(path) { + const file = await open(path, 'r+') + try { + const { size } = await file.stat() + const header = Buffer.alloc(Math.min(PE_HEADER_READ_SIZE, size)) + await file.read(header, 0, header.length, 0) + const directoryOffset = findDanglingAuthenticodeDirectory(header, size) + if (directoryOffset === undefined) return false + await file.write(Buffer.alloc(8), 0, 8, directoryOffset) + return true + } + finally { + await file.close() + } +} + +/** + * Locate an Authenticode certificate-table entry whose declared bytes are outside the file. + * + * @param {Buffer} header Initial executable bytes. + * @param {number} fileSize Complete file size. + * @returns {number | undefined} File offset of the invalid data-directory entry. + */ +function findDanglingAuthenticodeDirectory(header, fileSize) { + if (header.length < 64 || header.toString('ascii', 0, 2) !== 'MZ') return undefined + const peOffset = header.readUInt32LE(60) + const optionalHeaderOffset = peOffset + 24 + if (optionalHeaderOffset + 2 > header.length + || header.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') return undefined + const magic = header.readUInt16LE(optionalHeaderOffset) + const dataDirectoryOffset = magic === PE32_MAGIC + ? optionalHeaderOffset + 96 + : magic === PE32_PLUS_MAGIC + ? optionalHeaderOffset + 112 + : undefined + if (dataDirectoryOffset === undefined) return undefined + const certificateDirectoryOffset = dataDirectoryOffset + (4 * 8) + if (certificateDirectoryOffset + 8 > header.length) return undefined + const certificateOffset = header.readUInt32LE(certificateDirectoryOffset) + const certificateSize = header.readUInt32LE(certificateDirectoryOffset + 4) + if (certificateOffset === 0 && certificateSize === 0) return undefined + return certificateOffset > 0 + && certificateSize > 0 + && certificateOffset + certificateSize <= fileSize + ? undefined + : certificateDirectoryOffset +} + +/** + * Sign electron-builder's temporary NSIS executable before enterprise code integrity evaluates it. + * + * @param {{ sign: (configuration: { path: string, hash: string, isNest: boolean }) => Promise, wineVmManager?: typeof WineVmManager, platform?: NodeJS.Platform, environment?: NodeJS.ProcessEnv }} options Signing hook and injectable host values. + * @returns {void} + */ +export function installWindowsNsisBootstrapSigner(options) { + if ((options.platform ?? process.platform) !== 'win32') return + const prototype = (options.wineVmManager ?? WineVmManager).prototype + if (prototype[NSIS_BOOTSTRAP_PATCH] === true) return + const originalExec = prototype.exec + prototype.exec = async function (file, args, execOptions, isLogOutIfDebug) { + const isNsisBootstrap = file.toLowerCase().endsWith('.exe') + && execOptions?.env?.__COMPAT_LAYER === NSIS_RUN_AS_INVOKER + if (!isNsisBootstrap) { + return originalExec.call(this, file, args, execOptions, isLogOutIfDebug) + } + await options.sign({ path: file, hash: 'sha256', isNest: false }) + return originalExec.call(this, file, args, { + ...execOptions, + env: scrubWindowsSigningEnvironment({ + ...(options.environment ?? process.env), + ...execOptions.env, + }), + }, isLogOutIfDebug) + } + Object.defineProperty(prototype, NSIS_BOOTSTRAP_PATCH, { value: true }) +} diff --git a/apps/desktop/src/core-package-set.ts b/apps/desktop/src/core-package-set.ts new file mode 100644 index 0000000000..ff93bd5605 --- /dev/null +++ b/apps/desktop/src/core-package-set.ts @@ -0,0 +1,183 @@ +/** Signed local npm package set that supplies the Desktop-owned dsh runtime and private Host. */ + +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Descriptor copied beside every Desktop profile's local core tarballs. */ +export const DESKTOP_PACKAGE_SET_FILE = 'desktop-packages.json' + +/** Profile-relative directory containing immutable core npm tarballs. */ +export const DESKTOP_PACKAGES_DIR = 'desktop-packages' + +/** Private package installed beside dsh to boot the Desktop Host process. */ +export const DESKTOP_HOST_PACKAGE = '@deepseek-ai/dsh-desktop-host' + +/** Package-relative Desktop Host files required before a profile can boot. */ +export const DESKTOP_HOST_RUNTIME_FILES = [ + 'lib/index.js', + 'config/desktop.cordis.patch.yml', +] as const + +/** One immutable npm tarball in the Desktop core package set. */ +export interface DesktopCorePackageRecord { + readonly name: string + readonly version: string + readonly file: string + readonly bytes: number + readonly integrity: string +} + +/** Complete union of the first-party package closures rooted at dsh and its private Desktop Host. */ +export interface DesktopCorePackageSet { + readonly schemaVersion: 1 + readonly packages: readonly DesktopCorePackageRecord[] +} + +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u +const FILE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.tgz$/u +const INTEGRITY_PATTERN = /^sha512-[A-Za-z0-9+/]+={0,2}$/u +const DSH_PACKAGE = '@deepseek-ai/dsh' +const RELEASE_PACKAGES = [DSH_PACKAGE, DESKTOP_HOST_PACKAGE] as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Validate package-set data read from a release artifact or active profile. + * @param value - Parsed descriptor JSON. + * @param expectedReleaseVersion - Required dsh and Desktop Host version when validating one release. + * @returns The normalized package set in deterministic name order. + */ +export function parseDesktopCorePackageSet( + value: unknown, + expectedReleaseVersion?: string, +): DesktopCorePackageSet { + if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.packages)) { + throw new Error('desktop package set: invalid descriptor') + } + const packages = value.packages.map((entry): DesktopCorePackageRecord => { + if (!isRecord(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME_PATTERN.test(entry.name) + || typeof entry.version !== 'string' || !VERSION_PATTERN.test(entry.version) + || typeof entry.file !== 'string' || !FILE_PATTERN.test(entry.file) + || typeof entry.bytes !== 'number' || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 + || typeof entry.integrity !== 'string' || !INTEGRITY_PATTERN.test(entry.integrity)) { + throw new Error('desktop package set: invalid package record') + } + return { + name: entry.name, + version: entry.version, + file: entry.file, + bytes: entry.bytes, + integrity: entry.integrity, + } + }) + const names = new Set(packages.map(entry => entry.name)) + const files = new Set(packages.map(entry => entry.file)) + if (names.size !== packages.length || files.size !== packages.length) { + throw new Error('desktop package set: duplicate package name or filename') + } + const sorted = [...packages].sort((left, right) => left.name.localeCompare(right.name)) + if (JSON.stringify(sorted) !== JSON.stringify(packages)) { + throw new Error('desktop package set: packages must be sorted by name') + } + for (const name of RELEASE_PACKAGES) { + const entry = packages.find(candidate => candidate.name === name) + if (entry === undefined) throw new Error(`desktop package set: missing ${name}`) + if (expectedReleaseVersion !== undefined && entry.version !== expectedReleaseVersion) { + throw new Error(`desktop package set: ${name}@${entry.version} does not match Desktop ${expectedReleaseVersion}`) + } + } + return { schemaVersion: 1, packages } +} + +/** Read and structurally validate one profile's core package descriptor. */ +export function readDesktopCorePackageSet(projectDir: string, expectedReleaseVersion?: string): DesktopCorePackageSet { + const path = join(projectDir, DESKTOP_PACKAGE_SET_FILE) + let value: unknown + try { + value = JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error(`desktop package set: failed to read ${path}: ${String(error)}`) + } + return parseDesktopCorePackageSet(value, expectedReleaseVersion) +} + +/** Return the project-relative `file:` spec for one local core tarball. */ +export function desktopCorePackageSpec(record: DesktopCorePackageRecord): string { + return `file:./${DESKTOP_PACKAGES_DIR}/${record.file}` +} + +/** Return the exact pnpm override map that keeps every core package off registries. */ +export function desktopCorePackageOverrides(packageSet: DesktopCorePackageSet): Record { + return Object.fromEntries(packageSet.packages.map(record => [record.name, desktopCorePackageSpec(record)])) +} + +/** Return the local direct dependency spec for the dsh package. */ +export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string { + const record = packageSet.packages.find(entry => entry.name === DSH_PACKAGE) + if (record === undefined) throw new Error(`desktop package set: missing ${DSH_PACKAGE}`) + return desktopCorePackageSpec(record) +} + +/** + * Verify every local tarball and reject extra package files before pnpm executes them. + * @param projectDir - Seed or profile directory containing the package set. + * @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron. + * @returns The verified package set. + */ +export function verifyDesktopCorePackageSet( + projectDir: string, + expectedReleaseVersion: string, +): DesktopCorePackageSet { + const packageSet = readDesktopCorePackageSet(projectDir, expectedReleaseVersion) + const packageDir = join(projectDir, DESKTOP_PACKAGES_DIR) + const expectedFiles = packageSet.packages.map(entry => entry.file).sort() + let actualFiles: string[] + try { + actualFiles = readdirSync(packageDir).sort() + } catch (error) { + throw new Error(`desktop package set: failed to read ${packageDir}: ${String(error)}`) + } + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + throw new Error('desktop package set: package directory does not match its descriptor') + } + for (const record of packageSet.packages) { + const path = join(packageDir, record.file) + if (!existsSync(path) || !lstatSync(path).isFile()) { + throw new Error(`desktop package set: ${record.file} is not a regular file`) + } + const body = readFileSync(path) + const integrity = `sha512-${createHash('sha512').update(body).digest('base64')}` + if (body.byteLength !== record.bytes || integrity !== record.integrity) { + throw new Error(`desktop package set: integrity check failed for ${record.file}`) + } + } + return packageSet +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&') +} + +/** + * Reject a lockfile that resolved any packaged core name through a registry version. + * @param lockfile - Generated pnpm lockfile text. + * @param packageSet - Verified local core package set. + */ +export function verifyDesktopCoreLockfile( + lockfile: string, + packageSet: DesktopCorePackageSet, +): void { + for (const record of packageSet.packages) { + const registryResolution = new RegExp( + `^ ['"]?${escapeRegExp(record.name)}@${escapeRegExp(record.version)}(?:\\([^\\r\\n]*\\))?['"]?:`, + 'mu', + ) + if (registryResolution.test(lockfile)) { + throw new Error(`desktop package set: lockfile resolved ${record.name}@${record.version} outside the local package set`) + } + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts new file mode 100644 index 0000000000..ed18d0c7d6 --- /dev/null +++ b/apps/desktop/src/host-process.ts @@ -0,0 +1,413 @@ +/** Upstream-Node child lifecycle and streaming custom-protocol carrier. */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { once } from 'node:events' +import { join } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { + DESKTOP_HOST_PROTOCOL_VERSION, + DESKTOP_PIPE_CHUNK_BYTES, + DESKTOP_REQUEST_PIPE_FD, + DESKTOP_RESPONSE_PIPE_FD, + DesktopHostResponseDecoder, + encodeDesktopRequestCancel, + encodeDesktopRequestData, + encodeDesktopRequestEnd, + encodeDesktopRequestStart, + type DesktopHostCommand, + type DesktopHostEvent, + type DesktopHostResponseFrame, +} from './host-protocol.ts' + +interface PendingResponse { + readonly resolve: (response: Response) => void + readonly reject: (error: Error) => void + responseStarted: boolean + uploadOpen: boolean + controller?: ReadableStreamDefaultController + requestReader?: ReadableStreamDefaultReader + removeAbort?: () => void +} + +function isDesktopHostEvent(message: unknown): message is DesktopHostEvent { + if (typeof message !== 'object' || message === null || !('type' in message)) return false + const candidate = message as Record + switch (candidate.type) { + case 'ready': + return candidate.protocolVersion === DESKTOP_HOST_PROTOCOL_VERSION && typeof candidate.dshVersion === 'string' + case 'fatal': + return typeof candidate.message === 'string' + default: + return false + } +} + +function errorOf(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback) +} + +async function exitsWithin(exit: Promise, milliseconds: number): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { resolve(false) }, milliseconds) + timer.unref() + }) + try { + return await Promise.race([exit.then(() => true), timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +/** Ready facts reported by one installed dsh child. */ +export interface DesktopHostReady { + readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly dshVersion: string +} + +/** One dsh backend running under the bundled upstream Node.js executable. */ +export class DesktopHostProcess { + private child: ChildProcess | undefined + private requestPipe: Writable | undefined + private responsePipe: Readable | undefined + private readonly responseDecoder = new DesktopHostResponseDecoder() + private requestWriteTail: Promise = Promise.resolve() + private nextStreamId = 1 + private readonly pending = new Map() + private readonly blockedResponses = new Set() + private readyResolve!: (ready: DesktopHostReady) => void + private readyReject!: (error: Error) => void + private readonly readyPromise = new Promise((resolve, reject) => { + this.readyResolve = resolve + this.readyReject = reject + }) + private exitPromise: Promise | undefined + private stderr = '' + + /** + * @param node - absolute bundled upstream Node.js executable. + * @param projectDir - active or staged desktop npm project. + * @param inspectPort - optional loopback inspector port for workspace development. + */ + constructor( + private readonly node: string, + private readonly projectDir: string, + private readonly inspectPort?: number, + ) {} + + /** 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 child = spawn(this.node, [ + ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]), + entry, + 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) + ))), + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'], + }) + const requestPipe = child.stdio[DESKTOP_REQUEST_PIPE_FD] + const responsePipe = child.stdio[DESKTOP_RESPONSE_PIPE_FD] + if (!(requestPipe instanceof Writable) || !(responsePipe instanceof Readable)) { + child.kill('SIGTERM') + throw new Error('dsh desktop host did not expose the required byte pipes and IPC channel') + } + this.child = child + this.requestPipe = requestPipe + this.responsePipe = responsePipe + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', (chunk: string) => { this.stderr += chunk }) + child.stdout?.pipe(process.stdout) + responsePipe.on('data', (chunk: Buffer) => { this.acceptResponseBytes(chunk) }) + responsePipe.once('end', () => { + try { + this.responseDecoder.finish() + this.fail(new Error('dsh desktop host response pipe ended')) + } catch (error) { + this.fail(errorOf(error, 'dsh desktop host response pipe failed')) + } + }) + requestPipe.once('error', (error) => { this.fail(error) }) + responsePipe.once('error', (error) => { this.fail(error) }) + child.on('message', (message: unknown) => { + if (!isDesktopHostEvent(message)) { + this.fail(new Error('dsh desktop host sent an invalid IPC event')) + child.kill('SIGTERM') + return + } + this.handleMessage(message) + }) + child.once('error', (error) => { this.fail(error) }) + this.exitPromise = new Promise((resolve) => { + child.once('exit', (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}`)) + resolve() + }) + }) + return this.readyPromise + } + + /** Forward one `dsh-app://app` request to the child without buffering its body. */ + async fetch(request: Request): Promise { + await this.start() + const child = this.child + if (child === undefined || !child.connected || this.requestPipe === undefined) { + throw new Error('dsh desktop host is unavailable') + } + if (this.nextStreamId > 0xffff_ffff) throw new Error('dsh desktop host exhausted its request stream ids') + const streamId = this.nextStreamId++ + const method = request.method.toUpperCase() + const hasBody = method !== 'GET' && method !== 'HEAD' && request.body !== null + return new Promise((resolve, reject) => { + const pending: PendingResponse = { + resolve, + reject, + responseStarted: false, + uploadOpen: hasBody, + } + const abort = (): void => { + if (!this.pending.has(streamId)) return + const error = errorOf(request.signal.reason, 'request aborted') + pending.uploadOpen = false + void pending.requestReader?.cancel(error).catch(() => undefined) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => { + this.fail(errorOf(pipeError, 'dsh desktop request pipe failed')) + }) + if (pending.controller === undefined) pending.reject(error) + else pending.controller.error(error) + this.finishPending(streamId, false) + } + if (request.signal.aborted) { + reject(errorOf(request.signal.reason, 'request aborted')) + return + } + request.signal.addEventListener('abort', abort, { once: true }) + pending.removeAbort = () => { request.signal.removeEventListener('abort', abort) } + this.pending.set(streamId, pending) + this.pumpRequest(streamId, request, hasBody).catch((error: unknown) => { + this.failPending(streamId, errorOf(error, 'dsh desktop request upload failed')) + }) + }) + } + + /** Request graceful teardown, then wait for child exit. */ + async stop(): Promise { + const child = this.child + if (child === undefined) return + this.blockedResponses.clear() + this.responsePipe?.resume() + if (child.connected) this.send({ type: 'shutdown' }) + // Closing the parent-owned write end releases the Host's pending Windows pipe read. + this.requestPipe?.destroy() + const exited = this.exitPromise ?? Promise.resolve() + if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM') + if (!await exitsWithin(exited, 5_000)) { + child.kill('SIGKILL') + if (!await exitsWithin(exited, 5_000)) { + throw new Error('dsh desktop host did not exit after SIGKILL') + } + } + this.child = undefined + this.requestPipe = undefined + this.responsePipe = undefined + } + + private async pumpRequest(streamId: number, request: Request, hasBody: boolean): Promise { + await this.enqueueRequestFrame(encodeDesktopRequestStart(streamId, { + url: request.url, + method: request.method.toUpperCase(), + headers: [...request.headers.entries()], + hasBody, + })) + if (!hasBody) return + const body = request.body + if (body === null) throw new Error('dsh desktop request body disappeared before upload') + const reader = body.getReader() + const pending = this.pending.get(streamId) + if (pending === undefined) { + await reader.cancel() + return + } + pending.requestReader = reader + try { + for (;;) { + const next = await reader.read() + if (next.done) break + for (let offset = 0; offset < next.value.byteLength; offset += DESKTOP_PIPE_CHUNK_BYTES) { + if (!this.pending.has(streamId)) return + await this.enqueueRequestFrame(encodeDesktopRequestData( + streamId, + next.value.subarray(offset, offset + DESKTOP_PIPE_CHUNK_BYTES), + )) + } + } + const live = this.pending.get(streamId) + if (live !== undefined) { + await this.enqueueRequestFrame(encodeDesktopRequestEnd(streamId)) + live.uploadOpen = false + } + } finally { + reader.releaseLock() + const live = this.pending.get(streamId) + if (live?.requestReader === reader) delete live.requestReader + } + } + + private enqueueRequestFrame(frame: Buffer): Promise { + const write = this.requestWriteTail.then(async () => { + const pipe = this.requestPipe + if (pipe === undefined || pipe.destroyed) throw new Error('dsh desktop host request pipe is unavailable') + if (!pipe.write(frame)) await once(pipe, 'drain') + }) + this.requestWriteTail = write.catch(() => undefined) + return write + } + + 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) + } + + private acceptResponseBytes(chunk: Buffer): void { + try { + for (const frame of this.responseDecoder.push(chunk)) this.handleResponseFrame(frame) + } catch (error) { + this.fail(errorOf(error, 'dsh desktop host response pipe failed')) + this.child?.kill('SIGTERM') + } + } + + private handleResponseFrame(frame: DesktopHostResponseFrame): void { + const pending = this.pending.get(frame.streamId) + if (pending === undefined) { + if (frame.streamId >= this.nextStreamId) { + throw new Error(`dsh desktop host responded for unknown stream ${String(frame.streamId)}`) + } + return + } + switch (frame.type) { + case 'start': { + if (pending.responseStarted) throw new Error(`dsh desktop host started stream ${String(frame.streamId)} twice`) + pending.responseStarted = true + let body: ReadableStream | null = null + if (frame.hasBody) { + body = new ReadableStream({ + start: (controller) => { pending.controller = controller }, + pull: () => { + this.blockedResponses.delete(frame.streamId) + this.resumeResponsePipe() + }, + cancel: (reason) => { this.cancelResponse(frame.streamId, reason) }, + }) + } + pending.resolve(new Response(body, { + status: frame.status, + headers: new Headers(frame.headers.map(([name, value]) => [name, value] as [string, string])), + })) + return + } + case 'data': { + const controller = pending.controller + if (!pending.responseStarted || controller === undefined) { + throw new Error(`dsh desktop host sent body data before a body start for stream ${String(frame.streamId)}`) + } + controller.enqueue(frame.data) + if ((controller.desiredSize ?? 0) <= 0) { + this.blockedResponses.add(frame.streamId) + this.responsePipe?.pause() + } + return + } + case 'end': + if (!pending.responseStarted) { + throw new Error(`dsh desktop host ended stream ${String(frame.streamId)} before its response start`) + } + pending.controller?.close() + this.finishPending(frame.streamId, true) + return + case 'error': + this.failPending(frame.streamId, new Error(frame.message)) + return + default: + frame satisfies never + } + } + + private cancelResponse(streamId: number, reason: unknown): void { + const pending = this.pending.get(streamId) + if (pending === undefined) return + pending.uploadOpen = false + void pending.requestReader?.cancel(reason).catch(() => undefined) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => { + this.fail(errorOf(error, 'dsh desktop request pipe failed')) + }) + this.finishPending(streamId, false) + } + + private failPending(streamId: number, error: Error): void { + const pending = this.pending.get(streamId) + if (pending === undefined) return + pending.uploadOpen = false + void pending.requestReader?.cancel(error).catch(() => undefined) + if (pending.controller === undefined) pending.reject(error) + else pending.controller.error(error) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((pipeError: unknown) => { + this.fail(errorOf(pipeError, 'dsh desktop request pipe failed')) + }) + this.finishPending(streamId, false) + } + + private finishPending(streamId: number, cancelOpenUpload: boolean): void { + const pending = this.pending.get(streamId) + if (pending === undefined) return + if (cancelOpenUpload && pending.uploadOpen) { + pending.uploadOpen = false + void pending.requestReader?.cancel().catch(() => undefined) + this.enqueueRequestFrame(encodeDesktopRequestCancel(streamId)).catch((error: unknown) => { + this.fail(errorOf(error, 'dsh desktop request pipe failed')) + }) + } + pending.removeAbort?.() + this.pending.delete(streamId) + this.blockedResponses.delete(streamId) + this.resumeResponsePipe() + } + + private resumeResponsePipe(): void { + if (this.blockedResponses.size === 0) this.responsePipe?.resume() + } + + private handleMessage(message: DesktopHostEvent): void { + switch (message.type) { + case 'ready': + this.readyResolve(message) + return + case 'fatal': + this.fail(new Error(message.message)) + return + default: + message satisfies never + } + } + + private fail(error: Error): void { + this.readyReject(error) + for (const pending of this.pending.values()) { + void pending.requestReader?.cancel(error).catch(() => undefined) + if (pending.controller === undefined) pending.reject(error) + else pending.controller.error(error) + pending.removeAbort?.() + } + this.pending.clear() + this.blockedResponses.clear() + this.responsePipe?.resume() + } +} diff --git a/apps/desktop/src/host-protocol.ts b/apps/desktop/src/host-protocol.ts new file mode 100644 index 0000000000..d057d6d16d --- /dev/null +++ b/apps/desktop/src/host-protocol.ts @@ -0,0 +1,215 @@ +/** Versioned control messages and framed byte transport for the Desktop Host child. */ + +/** Protocol version implemented by the Electron shell and installed dsh Host. */ +export const DESKTOP_HOST_PROTOCOL_VERSION = 3 as const + +/** Child descriptor Electron writes request frames to. */ +export const DESKTOP_REQUEST_PIPE_FD = 3 + +/** Child descriptor Electron reads response frames from. */ +export const DESKTOP_RESPONSE_PIPE_FD = 4 + +/** Child descriptor reserved for Node's lifecycle IPC channel. */ +export const DESKTOP_CONTROL_IPC_FD = 5 + +/** Maximum raw body bytes carried by one data frame. */ +export const DESKTOP_PIPE_CHUNK_BYTES = 64 * 1024 + +const FRAME_MAGIC = 0x44534833 +const FRAME_HEADER_BYTES = 13 +const MAX_CONTROL_PAYLOAD_BYTES = 1024 * 1024 + +const REQUEST_FRAME_START = 1 +const REQUEST_FRAME_DATA = 2 +const REQUEST_FRAME_END = 3 +const REQUEST_FRAME_CANCEL = 4 +type RequestFrameType = typeof REQUEST_FRAME_START | typeof REQUEST_FRAME_DATA + | typeof REQUEST_FRAME_END | typeof REQUEST_FRAME_CANCEL + +const RESPONSE_FRAME_START = 1 +const RESPONSE_FRAME_DATA = 2 +const RESPONSE_FRAME_END = 3 +const RESPONSE_FRAME_ERROR = 4 + +/** Metadata that precedes one optional request body on the request pipe. */ +export interface DesktopHostRequestStart { + readonly url: string + readonly method: string + readonly headers: readonly [string, string][] + readonly hasBody: boolean +} + +/** Commands retained on Node IPC because they do not carry Fetch payload bytes. */ +export type DesktopHostCommand = { + readonly type: 'shutdown' +} + +/** Lifecycle events retained on Node IPC. */ +export type DesktopHostEvent = { + readonly type: 'ready' + readonly protocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly dshVersion: string +} | { + readonly type: 'fatal' + readonly message: string +} + +/** One decoded response-pipe frame. */ +export type DesktopHostResponseFrame = { + readonly type: 'start' + readonly streamId: number + readonly status: number + readonly headers: readonly [string, string][] + readonly hasBody: boolean +} | { + readonly type: 'data' + readonly streamId: number + readonly data: Buffer +} | { + readonly type: 'end' + readonly streamId: number +} | { + readonly type: 'error' + readonly streamId: number + readonly message: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isHeaders(value: unknown): value is readonly [string, string][] { + return Array.isArray(value) && value.every(header => Array.isArray(header) && header.length === 2 + && typeof header[0] === 'string' && typeof header[1] === 'string') +} + +function assertStreamId(streamId: number): void { + if (!Number.isInteger(streamId) || streamId < 1 || streamId > 0xffff_ffff) { + throw new Error(`dsh desktop: invalid pipe stream id ${String(streamId)}`) + } +} + +function encodeFrame(type: RequestFrameType, streamId: number, payload: Buffer): Buffer { + assertStreamId(streamId) + const limit = type === REQUEST_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES + if (payload.byteLength > limit) { + throw new Error(`dsh desktop: request pipe frame exceeds the ${String(limit)}-byte limit`) + } + const frame = Buffer.allocUnsafe(FRAME_HEADER_BYTES + payload.byteLength) + frame.writeUInt32BE(FRAME_MAGIC, 0) + frame.writeUInt8(type, 4) + frame.writeUInt32BE(streamId, 5) + frame.writeUInt32BE(payload.byteLength, 9) + payload.copy(frame, FRAME_HEADER_BYTES) + return frame +} + +function encodeJsonFrame(type: RequestFrameType, streamId: number, value: unknown): Buffer { + return encodeFrame(type, streamId, Buffer.from(JSON.stringify(value), 'utf8')) +} + +/** Encode the metadata opening one request stream. */ +export function encodeDesktopRequestStart(streamId: number, request: DesktopHostRequestStart): Buffer { + return encodeJsonFrame(REQUEST_FRAME_START, streamId, request) +} + +/** Encode one bounded raw request-body chunk. */ +export function encodeDesktopRequestData(streamId: number, data: Uint8Array): Buffer { + return encodeFrame(REQUEST_FRAME_DATA, streamId, Buffer.from(data)) +} + +/** Encode normal request-body completion. */ +export function encodeDesktopRequestEnd(streamId: number): Buffer { + return encodeFrame(REQUEST_FRAME_END, streamId, Buffer.alloc(0)) +} + +/** Encode cancellation of one request and its response. */ +export function encodeDesktopRequestCancel(streamId: number): Buffer { + return encodeFrame(REQUEST_FRAME_CANCEL, streamId, Buffer.alloc(0)) +} + +/** Incrementally decode validated response frames from the Host byte pipe. */ +export class DesktopHostResponseDecoder { + private buffer: Buffer = Buffer.alloc(0) + + /** + * Append bytes and return every complete response frame. + * @param chunk - next bytes read from the Host response pipe. + * @returns complete frames in pipe order. + */ + push(chunk: Buffer): DesktopHostResponseFrame[] { + this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk]) + const frames: DesktopHostResponseFrame[] = [] + for (;;) { + const frame = this.next() + if (frame === undefined) return frames + frames.push(frame) + } + } + + /** Reject EOF that splits a frame. */ + finish(): void { + if (this.buffer.byteLength !== 0) throw new Error('dsh desktop: Host response pipe ended inside a frame') + } + + private next(): DesktopHostResponseFrame | undefined { + if (this.buffer.byteLength < FRAME_HEADER_BYTES) return undefined + if (this.buffer.readUInt32BE(0) !== FRAME_MAGIC) throw new Error('dsh desktop: invalid Host response frame marker') + const rawType = this.buffer.readUInt8(4) + const streamId = this.buffer.readUInt32BE(5) + const payloadLength = this.buffer.readUInt32BE(9) + assertStreamId(streamId) + const limit = rawType === RESPONSE_FRAME_DATA ? DESKTOP_PIPE_CHUNK_BYTES : MAX_CONTROL_PAYLOAD_BYTES + if (payloadLength > limit) { + throw new Error(`dsh desktop: Host response frame exceeds the ${String(limit)}-byte limit`) + } + const frameLength = FRAME_HEADER_BYTES + payloadLength + if (this.buffer.byteLength < frameLength) return undefined + const payload = this.buffer.subarray(FRAME_HEADER_BYTES, frameLength) + this.buffer = this.buffer.subarray(frameLength) + switch (rawType) { + case RESPONSE_FRAME_START: + return this.parseStart(streamId, payload) + case RESPONSE_FRAME_DATA: + return { type: 'data', streamId, data: payload } + case RESPONSE_FRAME_END: + if (payloadLength !== 0) throw new Error('dsh desktop: Host response end frame carried a payload') + return { type: 'end', streamId } + case RESPONSE_FRAME_ERROR: + return this.parseError(streamId, payload) + default: + throw new Error(`dsh desktop: unknown Host response frame type ${String(rawType)}`) + } + } + + private parseStart(streamId: number, payload: Buffer): DesktopHostResponseFrame { + const value = this.parseJson(payload, 'start') + if (!isRecord(value) || !Number.isInteger(value.status) || (value.status as number) < 100 + || (value.status as number) > 599 || !isHeaders(value.headers) || typeof value.hasBody !== 'boolean') { + throw new Error('dsh desktop: invalid Host response start payload') + } + return { + type: 'start', + streamId, + status: value.status as number, + headers: value.headers, + hasBody: value.hasBody, + } + } + + private parseError(streamId: number, payload: Buffer): DesktopHostResponseFrame { + const value = this.parseJson(payload, 'error') + if (!isRecord(value) || typeof value.message !== 'string') { + throw new Error('dsh desktop: invalid Host response error payload') + } + return { type: 'error', streamId, message: value.message } + } + + private parseJson(payload: Buffer, subject: string): unknown { + try { + return JSON.parse(payload.toString('utf8')) as unknown + } catch (error) { + throw new Error(`dsh desktop: Host response ${subject} payload is not JSON: ${error instanceof Error ? error.message : String(error)}`) + } + } +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts new file mode 100644 index 0000000000..4fb946a2a5 --- /dev/null +++ b/apps/desktop/src/ipc.ts @@ -0,0 +1,40 @@ +/** Typed preload operations exposed only by the Electron shell. */ + +import type { DesktopPluginRecord } from './project-manager.ts' +import type { DesktopLocale } from './locale.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', + pluginsAdd: 'dsh-desktop:plugins-add', + pluginsRemove: 'dsh-desktop:plugins-remove', + pluginsUpdate: 'dsh-desktop:plugins-update', + updatesCheck: 'dsh-desktop:updates-check', + updatesInstall: 'dsh-desktop:updates-install', + updatesState: 'dsh-desktop:updates-state', +} as const + +/** Desktop release update state rendered by desktop-owned UI. */ +export interface DesktopUpdateState { + readonly phase: 'idle' | 'checking' | 'available' | 'installing' | 'ready' | 'error' + readonly version?: string + readonly message?: string +} + +/** Narrow bridge exposed through context isolation. */ +export interface DshDesktopApi { + readonly protocolVersion: 1 + locale(): Promise + readonly plugins: { + list(): Promise + add(spec: string): Promise + remove(name: string): Promise + update(name: string, version: string): Promise + } + readonly updates: { + check(): Promise + install(): Promise + subscribe(listener: (state: DesktopUpdateState) => void): () => void + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts new file mode 100644 index 0000000000..b666cfd559 --- /dev/null +++ b/apps/desktop/src/locale.ts @@ -0,0 +1,97 @@ +/** Typed English and Chinese copy owned by the Electron shell. */ + +export const en = { + application: 'Application', + startupFailed: 'DeepSeek Harness could not start', + pluginsMenu: 'Desktop Plugins…', + pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', + checkUpdatesMenu: 'Check for Updates…', + updateCheckFailedTitle: 'Update Check Failed', + unknownError: 'Unknown error', + updateCheckTitle: 'Check for Updates', + updateCurrent: 'You already have the latest version.', + updateTitle: 'DeepSeek Harness Update', + updateAvailable: 'An update is available', + updateDetail: 'DeepSeek Harness {version}\n\nThis release includes its matching dsh version. The application will restart after installation.', + installAndRestart: 'Install and Restart', + later: 'Later', + updateFailedTitle: 'Update Failed', + pluginManagerTitle: 'Desktop Plugins', + pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins', + pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.', + refresh: 'Refresh', + npmPackage: 'npm package', + install: 'Install', + installed: 'Installed', + noPlugins: 'No Desktop plugins are installed.', + remove: 'Remove', + update: 'Update', + targetVersion: 'Enter the target version for {name}', + removing: 'Removing {name}…', + updating: 'Updating {name}…', + installing: 'Installing {spec}…', + operationComplete: 'Done. The Desktop backend has restarted.', + refreshing: 'Refreshing…', + refreshed: 'Plugin list refreshed.', + loadingPlugins: 'Reading Desktop plugins…', +} as const + +/** Every Desktop locale supplies the complete English key set. */ +export type DesktopMessages = { readonly [Key in keyof typeof en]: string } + +export const zh = { + application: '应用', + startupFailed: 'DeepSeek Harness 无法启动', + pluginsMenu: '桌面插件…', + pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', + checkUpdatesMenu: '检查更新…', + updateCheckFailedTitle: '更新检查失败', + unknownError: '未知错误', + updateCheckTitle: '检查更新', + updateCurrent: '当前已是最新版本。', + updateTitle: 'DeepSeek Harness 更新', + updateAvailable: '发现可用更新', + updateDetail: 'DeepSeek Harness {version}\n\n新版本绑定匹配的 dsh,安装后将重新启动。', + installAndRestart: '安装并重启', + later: '稍后', + updateFailedTitle: '更新失败', + pluginManagerTitle: '桌面插件', + pluginWindowTitle: 'DeepSeek Harness — 桌面插件', + pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', + refresh: '刷新', + npmPackage: 'npm 包', + install: '安装', + installed: '已安装', + noPlugins: '还没有安装桌面插件。', + remove: '移除', + update: '更新', + targetVersion: '输入 {name} 的目标版本', + removing: '正在移除 {name}…', + updating: '正在更新 {name}…', + installing: '正在安装 {spec}…', + operationComplete: '操作完成,桌面后端已重新启动。', + refreshing: '正在刷新…', + refreshed: '插件列表已刷新。', + loadingPlugins: '正在读取桌面插件…', +} as const satisfies DesktopMessages + +/** Locale payload exposed to the Desktop-owned renderer. */ +export interface DesktopLocale { + readonly id: 'en' | 'zh-CN' + readonly messages: DesktopMessages +} + +/** Resolve Electron's locale to one shipped Desktop dictionary. */ +export function resolveDesktopLocale(locale: string): DesktopLocale { + return locale.toLowerCase().startsWith('zh') + ? { id: 'zh-CN', messages: zh } + : { id: 'en', messages: en } +} + +/** Replace named placeholders in one locale-owned message. */ +export function formatDesktopMessage( + message: string, + values: Readonly>, +): string { + return message.replaceAll(/\{([^{}]+)\}/gu, (placeholder, key: string) => values[key] ?? placeholder) +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 0000000000..8bc9fdcefd --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,397 @@ +/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */ + +import { readFile, writeFile } from 'node:fs/promises' +import { extname, join, normalize, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + protocol, + type IpcMainInvokeEvent, +} from 'electron' +import { resolveDesktopPaths } from './paths.ts' +import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' +import { DesktopHostProcess } from './host-process.ts' +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' + +const SCHEME = 'dsh-app' +let focusPrimaryWindow = (): void => {} + +function errorOf(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback) +} + +protocol.registerSchemesAsPrivileged([{ + scheme: SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: false, + stream: true, + codeCache: true, + }, +}]) + +const MIME: Readonly> = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.svg': 'image/svg+xml', +} + +interface RuntimeResources { + readonly node: string + readonly pnpm: string + readonly seed: string +} + +function runtimeResources(): RuntimeResources { + const development = !app.isPackaged + const node = (development ? process.env.DSH_DESKTOP_NODE_BINARY : undefined) + ?? 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 } +} + +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 + const port = Number(configured) + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error('dsh desktop: DSH_DESKTOP_HOST_INSPECT_PORT must be an integer from 1 through 65535') + } + return port +} + +function createWindow(preload: string): BrowserWindow { + const window = new BrowserWindow({ + width: 1280, + height: 840, + minWidth: 880, + minHeight: 600, + show: false, + webPreferences: { + preload, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + }, + }) + window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + window.webContents.on('will-navigate', (event, url) => { + if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault() + }) + return window +} + +function assertDesktopSender(event: IpcMainInvokeEvent, hostnames: readonly string[]): void { + const senderFrame = event.senderFrame + if (senderFrame === null) throw new Error('dsh desktop: rejected IPC without a sender frame') + const url = new URL(senderFrame.url) + if (url.protocol !== `${SCHEME}:` || !hostnames.includes(url.hostname)) { + throw new Error('dsh desktop: rejected IPC from an unowned renderer') + } +} + +async function serveShellAsset(request: Request): Promise { + if (request.method !== 'GET' && request.method !== 'HEAD') return new Response(null, { status: 405 }) + const root = resolve(app.getAppPath(), 'renderer') + const url = new URL(request.url) + let pathname: string + try { + pathname = decodeURIComponent(url.pathname) + } catch { + return new Response(null, { status: 400 }) + } + const target = resolve(normalize(join(root, pathname))) + if (target !== root && !target.startsWith(root + sep)) return new Response(null, { status: 403 }) + try { + const body = request.method === 'HEAD' ? null : await readFile(target) + return new Response(body, { headers: { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' } }) + } catch { + return new Response(null, { status: 404 }) + } +} + +async function main(): Promise { + const resources = runtimeResources() + const paths = resolveDesktopPaths() + const development = developmentProject() + 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 mainWindow: BrowserWindow | undefined + let pluginWindow: BrowserWindow | undefined + let shellInstallerOwnsQuit = false + let updateState: DesktopUpdateState = { phase: 'idle' } + const locale = resolveDesktopLocale(app.getLocale()) + 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 publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { + updateState = state + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.updatesState, state) + } + return state + } + + const startHost = async (projectDir = activeProject): Promise => { + const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort) + await next.start() + return next + } + 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() + }, + } + + if (development === undefined) { + await manager.applyRelease(resources.seed, app.getVersion(), { + ...hooks, + beforeActivate: async () => {}, + afterActivate: async () => {}, + }) + } + host = await startHost() + + const updates = new DesktopUpdateCoordinator( + publishUpdate, + async () => { + shellInstallerOwnsQuit = true + const active = host + host = undefined + await active?.stop() + }, + ) + + protocol.handle(SCHEME, (request) => { + 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 + if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) + return active.fetch(request) + }) + + const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters[0]): Promise => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) { + throw new Error('dsh desktop: plugin package changes require a packaged application') + } + await manager.mutate(mutation, hooks) + if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() + } + ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { + assertDesktopSender(event, ['shell']) + return locale + }) + ipcMain.handle(DESKTOP_IPC.pluginsList, (event) => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) return [] + return manager.listPlugins() + }) + ipcMain.handle(DESKTOP_IPC.pluginsAdd, (event, spec: unknown) => { + if (typeof spec !== 'string') throw new Error('dsh desktop: plugin spec must be a string') + return mutate(event, { type: 'plugin-add', spec }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsRemove, (event, name: unknown) => { + if (typeof name !== 'string') throw new Error('dsh desktop: plugin name must be a string') + return mutate(event, { type: 'plugin-remove', name }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsUpdate, (event, name: unknown, version: unknown) => { + if (typeof name !== 'string' || typeof version !== 'string') { + throw new Error('dsh desktop: plugin name and version must be strings') + } + return mutate(event, { type: 'plugin-update', name, version }) + }) + ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { + assertDesktopSender(event, ['shell']) + return updates.check() + }) + ipcMain.handle(DESKTOP_IPC.updatesInstall, async (event) => { + assertDesktopSender(event, ['shell']) + await updates.install() + }) + + const checkAndPrompt = async (manual: boolean): Promise => { + const state = await updates.check() + if (state.phase === 'error') { + if (manual) { + await dialog.showMessageBox({ + type: 'error', + title: messages.updateCheckFailedTitle, + message: state.message ?? messages.unknownError, + }) + } + return + } + if (state.phase !== 'available') { + if (manual) { + await dialog.showMessageBox({ + type: 'info', + title: messages.updateCheckTitle, + message: state.message ?? messages.updateCurrent, + }) + } + return + } + const result = await dialog.showMessageBox({ + type: 'info', + title: messages.updateTitle, + message: messages.updateAvailable, + detail: formatDesktopMessage(messages.updateDetail, { version: state.version ?? '' }), + buttons: [messages.installAndRestart, messages.later], + defaultId: 0, + cancelId: 1, + }) + if (result.response !== 0) return + const installed = await updates.install() + if (installed.phase === 'error') { + await dialog.showMessageBox({ + type: 'error', + title: messages.updateFailedTitle, + message: installed.message ?? messages.unknownError, + }) + } + } + + const openPluginWindow = (): void => { + if (pluginWindow !== undefined && !pluginWindow.isDestroyed()) { + pluginWindow.focus() + return + } + pluginWindow = createWindow(managementPreload) + pluginWindow.setSize(900, 620) + pluginWindow.setTitle(messages.pluginWindowTitle) + pluginWindow.once('ready-to-show', () => { pluginWindow?.show() }) + pluginWindow.once('closed', () => { pluginWindow = undefined }) + void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) + } + + Menu.setApplicationMenu(Menu.buildFromTemplate([{ + label: process.platform === 'darwin' ? app.name : messages.application, + submenu: [ + { + label: development === undefined ? messages.pluginsMenu : messages.pluginsMenuPackagedOnly, + accelerator: 'CmdOrCtrl+,', + enabled: development === undefined, + click: openPluginWindow, + }, + { label: messages.checkUpdatesMenu, click: () => { void checkAndPrompt(true) } }, + { type: 'separator' }, + { role: 'quit' }, + ], + }])) + + const createMainWindow = (): BrowserWindow => { + const window = createWindow(appPreload) + mainWindow = window + window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) + window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) + return window + } + focusPrimaryWindow = () => { + const window = mainWindow + if (window === undefined || window.isDestroyed()) { + const replacement = createMainWindow() + void replacement.loadURL(`${SCHEME}://app/index.html`) + return + } + if (window.isMinimized()) window.restore() + window.show() + window.focus() + } + + mainWindow = createMainWindow() + await mainWindow.loadURL(`${SCHEME}://app/index.html`) + if (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() + }) + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) + app.on('before-quit', (event) => { + if (shellInstallerOwnsQuit) return + if (host === undefined) return + event.preventDefault() + const active = host + host = undefined + void active.stop().finally(() => { app.quit() }) + }) +} + +const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() }) + +if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + console.error(error) + const diagnosticFile = process.env.DSH_DESKTOP_DIAGNOSTIC_FILE + 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) +}) diff --git a/apps/desktop/src/paths.ts b/apps/desktop/src/paths.ts new file mode 100644 index 0000000000..4f883929db --- /dev/null +++ b/apps/desktop/src/paths.ts @@ -0,0 +1,48 @@ +/** Filesystem ownership for the Electron-managed desktop installation. */ + +import { join } from 'node:path' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' + +/** Stable desktop installation paths under the shared Harness home. */ +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 + readonly store: string + readonly cache: string + readonly state: string + readonly config: string + readonly home: string + } +} + +/** + * Resolve every Electron-owned path without changing the shared data roots. + * @param dshHome - Harness home shared with npm-installed dsh. + * @returns immutable desktop path set. + */ +export function resolveDesktopPaths(dshHome: string = resolveDshHome()): DesktopPaths { + const root = join(dshHome, 'desktop') + const pnpm = join(root, 'pnpm') + 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'), + pnpm: { + root: pnpm, + store: join(pnpm, 'store'), + cache: join(pnpm, 'cache'), + state: join(pnpm, 'state'), + config: join(pnpm, 'config'), + home: join(pnpm, 'home'), + }, + } +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts new file mode 100644 index 0000000000..c6a839aba6 --- /dev/null +++ b/apps/desktop/src/preload-app.ts @@ -0,0 +1,5 @@ +/** Minimal marker that selects the desktop custom-protocol API carrier. */ + +import { contextBridge } from 'electron' + +contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts new file mode 100644 index 0000000000..2fc4dc8ee0 --- /dev/null +++ b/apps/desktop/src/preload.ts @@ -0,0 +1,26 @@ +/** Context-isolated renderer bridge for desktop package and update operations. */ + +import { contextBridge, ipcRenderer } from 'electron' +import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' + +const api: DshDesktopApi = { + protocolVersion: 1, + locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as Promise extends Promise ? T : never>, + plugins: { + 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, + update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, + }, + updates: { + check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, + install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdateState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesState, handle) } + }, + }, +} + +contextBridge.exposeInMainWorld('dshDesktop', api) diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts new file mode 100644 index 0000000000..b9b94f7ac8 --- /dev/null +++ b/apps/desktop/src/project-manager.ts @@ -0,0 +1,725 @@ +/** Transactional owner of the reserved desktop profile and its private pnpm state. */ + +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { + constants, + copyFileSync, + cpSync, + existsSync, + fsyncSync, + ftruncateSync, + lstatSync, + mkdirSync, + openSync, + closeSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, + writeSync, +} 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 + +/** Desktop plugin record derived from the installed profile. */ +export interface DesktopPluginRecord { + readonly name: string + readonly version: string +} + +/** Installed desktop project manifest slice. */ +interface DesktopProjectManifest { + readonly name: string + readonly private: true + readonly version: string + readonly dependencies: Record + readonly dsh: { + readonly profile: { + readonly bundles: string[] + } + } +} + +/** Journaled activation step used for crash recovery. */ +interface DesktopPendingTransaction { + readonly schemaVersion: 1 + readonly id: string + readonly stagingProfile: string + readonly step: 'prepared' | 'active-moved' | 'staging-activated' +} + +/** Exact executables the desktop shell bundles. */ +export interface DesktopRuntimeExecutables { + readonly node: string + readonly pnpm: string +} + +/** Hooks that bind project replacement to backend lifecycle and health. */ +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. */ + afterActivate(): Promise +} + +/** Supported dependency mutation. */ +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 +} + +const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' +const DSH_PACKAGE = '@deepseek-ai/dsh' +const CORE_BUILD_PACKAGE = '@deepseek-ai/dsh-subprocess-local' +const DESKTOP_PROFILE_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] as const +const WORKSPACE_SETTINGS = 'nodeLinker: hoisted\nautoInstallPeers: false\nstrictDepBuilds: true\n' +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u +const MAX_PNPM_DIAGNOSTIC_BYTES = 64 * 1024 +const DESKTOP_REGISTRY = 'https://registry.npmjs.org/' + +function errorOf(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback) +} + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, `${JSON.stringify(value, undefined, 2)}\n`, { mode: 0o600 }) +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')) +} + +function workspaceFile(overrides: Readonly> = {}): string { + const entries = Object.entries(overrides).sort(([left], [right]) => left.localeCompare(right)) + const overrideSection = entries.length === 0 + ? '' + : `overrides:\n${entries.map(([name, spec]) => ` ${JSON.stringify(name)}: ${JSON.stringify(spec)}`).join('\n')}\n` + const coreBuildSpec = overrides[CORE_BUILD_PACKAGE] + const coreBuildKey = coreBuildSpec === undefined + ? CORE_BUILD_PACKAGE + : `${CORE_BUILD_PACKAGE}@${coreBuildSpec.replace('file:./', 'file:')}` + 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 +} + +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)}`) +} + +function assertVersion(version: string): void { + if (!VERSION_PATTERN.test(version)) throw new Error(`desktop project: invalid exact version ${JSON.stringify(version)}`) +} + +/** + * Validate one registry package spec and return its requested package name when explicit. + * @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. + */ +export function packageNameFromSpec(spec: string): string | undefined { + 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)}`) + } + if (spec.startsWith('@')) { + const slash = spec.indexOf('/') + if (slash === -1) throw new Error(`desktop project: invalid scoped package spec ${JSON.stringify(spec)}`) + const versionAt = spec.indexOf('@', slash) + const name = versionAt === -1 ? spec : spec.slice(0, versionAt) + assertPackageName(name) + if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1)) + return name + } + const versionAt = spec.indexOf('@') + const name = versionAt === -1 ? spec : spec.slice(0, versionAt) + assertPackageName(name) + if (versionAt !== -1) assertVersion(spec.slice(versionAt + 1)) + 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 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) + || !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}`) + } + return manifest +} + +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') + } + 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') + } + for (const plugin of plugins) assertPackageName(plugin) + return plugins +} + +function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] { + return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name)) +} + +function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void { + const manifest = projectManifest(projectDir) + writeJson(join(projectDir, 'package.json'), { + ...manifest, + dsh: { + ...manifest.dsh, + profile: { + ...manifest.dsh.profile, + bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)], + }, + }, + } satisfies DesktopProjectManifest) +} + +function inspectPlugin(projectDir: string, requestedName: string): DesktopPluginRecord { + const manifestPath = join(projectDir, 'node_modules', ...requestedName.split('/'), 'package.json') + if (!existsSync(manifestPath)) { + throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has no manifest`) + } + const manifest = readJson(manifestPath) + if (!isRecord(manifest) || manifest.name !== requestedName || typeof manifest.version !== 'string') { + throw new Error(`desktop project: installed package ${JSON.stringify(requestedName)} has inconsistent name or version`) + } + const dsh = manifest.dsh + const bundle = isRecord(dsh) ? dsh.bundle : undefined + const patch = isRecord(bundle) ? bundle.patch : undefined + if (typeof patch !== 'string' || patch === '') { + throw new Error(`desktop project: ${requestedName}@${manifest.version} does not declare dsh.bundle.patch`) + } + const packageDir = dirname(manifestPath) + const patchPath = resolve(packageDir, patch) + 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 } +} + +/** Transactional desktop npm project manager. */ +export class DesktopProjectManager { + private lockDescriptor: number | undefined + + /** + * @param paths - Electron-owned package state and reserved desktop profile paths. + * @param runtime - absolute bundled Node.js and pnpm entry paths. + */ + constructor( + readonly paths: DesktopPaths, + 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.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, + step: value.step, + } + if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) { + 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) + } + + /** Read the exact dsh version installed in the active desktop project. */ + dshVersion(): string { + if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') + return this.installedPackageVersion(DSH_PACKAGE) + } + + 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. */ + releaseVersion(): string { + if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') + return releaseFile(this.paths.profile).version + } + + /** Install or reconcile the active project to the Electron package's exact release. */ + async applyRelease(seedDir: string, 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}`) + } + 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) + 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']) + } + await hooks.healthCheck(stagingProfile) + await this.activate(stagingProfile, hooks) + return true + } catch (error) { + removeOwnedDirectory(stagingProfile) + throw error + } + }) + } + + /** Apply one exact dependency mutation through a staging project. */ + async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { + await this.withLock(async () => { + this.recover() + 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) + await this.applyMutation(stagingProfile, mutation) + await hooks.healthCheck(stagingProfile) + await this.activate(stagingProfile, hooks) + } catch (error) { + removeOwnedDirectory(stagingProfile) + throw error + } + }) + } + + 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 { + switch (mutation.type) { + 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) + const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name) + writeProfilePlugins( + projectDir, + [...current, installed].sort((left, right) => left.name.localeCompare(right.name)), + ) + return + } + case 'plugin-remove': { + assertPackageName(mutation.name) + if (!profilePluginNames(projectDir).includes(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]) + writeProfilePlugins(projectDir, remaining) + return + } + case 'plugin-update': + assertPackageName(mutation.name) + assertVersion(mutation.version) + if (!profilePluginNames(projectDir).includes(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']) + { + const installed = inspectPlugin(projectDir, mutation.name) + writeProfilePlugins( + projectDir, + pluginRecords(projectDir).map(plugin => plugin.name === installed.name ? installed : 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, + step: 'prepared', + } + writeJson(this.paths.pending, pending) + await hooks.beforeActivate() + let activeMoved = false + try { + 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) + await hooks.afterActivate() + unlinkSync(this.paths.pending) + } catch (error) { + if (existsSync(this.paths.profile)) 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') + for (const path of [this.paths.root, this.paths.pnpm.store, this.paths.pnpm.cache, + this.paths.pnpm.state, this.paths.pnpm.config, this.paths.pnpm.home]) { + mkdirSync(path, { recursive: true, mode: 0o700 }) + } + 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) + ))) + await new Promise((settle, reject) => { + const child = spawn(this.runtime.node, [ + this.runtime.pnpm, + `--config.registry=${DESKTOP_REGISTRY}`, + `--config.store-dir=${this.paths.pnpm.store}`, + '--config.enable-global-virtual-store=false', + `--config.userconfig=${npmrc}`, + command, + ...commandArgs, + ], { + cwd: projectDir, + env: { + ...inherited, + COREPACK_HOME: this.paths.pnpm.home, + NPM_CONFIG_REGISTRY: DESKTOP_REGISTRY, + NPM_CONFIG_STORE_DIR: this.paths.pnpm.store, + NPM_CONFIG_USERCONFIG: npmrc, + PATH: `${dirname(this.runtime.node)}${delimiter}${process.env.PATH ?? ''}`, + PNPM_HOME: this.paths.pnpm.home, + XDG_CACHE_HOME: this.paths.pnpm.cache, + XDG_CONFIG_HOME: this.paths.pnpm.config, + XDG_STATE_HOME: this.paths.pnpm.state, + }, + 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 diagnostics = '' + let completed = false + const appendDiagnostics = (chunk: string): void => { + diagnostics = (diagnostics + chunk).slice(-MAX_PNPM_DIAGNOSTIC_BYTES) + } + child.stdout.setEncoding('utf8') + child.stdout.on('data', appendDiagnostics) + child.stderr.setEncoding('utf8') + child.stderr.on('data', appendDiagnostics) + const complete = (settleChild: () => void): void => { + if (completed) return + completed = true + try { + this.writeLockOwner(process.pid) + } catch (error) { + reject(errorOf(error, 'desktop project: failed to return the package transaction lock to Electron')) + return + } + settleChild() + } + child.once('error', (error) => { complete(() => { reject(error) }) }) + child.once('close', (code, signal) => { + complete(() => { + if (code === 0) { + settle() + return + } + reject(new Error( + `desktop project: pnpm exited with ${String(code ?? signal)}${diagnostics.trim() === '' ? '' : `: ${diagnostics.trim()}`}`, + )) + }) + }) + }) + } + + private writeLockOwner(pid: number): void { + const descriptor = this.lockDescriptor + if (descriptor === undefined) throw new Error('desktop project: package transaction lost its lock') + const content = Buffer.from(`${String(pid)}\n`) + ftruncateSync(descriptor, 0) + writeSync(descriptor, content, 0, content.byteLength, 0) + fsyncSync(descriptor) + } + + private async withLock(operation: () => Promise): Promise { + mkdirSync(this.paths.root, { recursive: true, mode: 0o700 }) + let descriptor: number + try { + descriptor = openSync(this.paths.lock, 'wx', 0o600) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + const lock = lstatSync(this.paths.lock) + if (lock.isSymbolicLink() || !lock.isFile()) { + throw new Error('desktop project: package transaction lock is not a regular file') + } + const owner = Number.parseInt(readFileSync(this.paths.lock, 'utf8').trim(), 10) + let active = !Number.isSafeInteger(owner) || owner <= 0 + if (!active) { + try { + process.kill(owner, 0) + active = true + } catch (signalError) { + active = (signalError as NodeJS.ErrnoException).code !== 'ESRCH' + } + } + if (active) throw new Error('desktop project: another package transaction is active') + unlinkSync(this.paths.lock) + descriptor = openSync(this.paths.lock, 'wx', 0o600) + } else { + throw error + } + } + try { + this.lockDescriptor = descriptor + this.writeLockOwner(process.pid) + return await operation() + } finally { + this.lockDescriptor = undefined + closeSync(descriptor) + unlinkSync(this.paths.lock) + } + } +} + +/** 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) + const manifest: DesktopProjectManifest = { + name: PROJECT_NAME, + private: true, + version: '0.0.0', + dependencies: desktopCorePackageOverrides(packageSet), + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } + writeJson(join(seedDir, 'package.json'), manifest) + writeFileSync( + join(seedDir, 'pnpm-workspace.yaml'), + workspaceFile(desktopCorePackageOverrides(packageSet)), + { mode: 0o600 }, + ) + writeJson(join(seedDir, 'desktop-release.json'), release) +} + +/** + * Create metadata for the unpackaged development project that links the current workspace. + * @param projectDir - Disposable development profile directory. + * @param release - Release identity shared by the linked CLI package and Electron shell. + */ +export function createDevelopmentProjectMetadata(projectDir: string, release: DesktopRelease): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + const manifest = { + name: PROJECT_NAME, + private: true, + version: '0.0.0', + dependencies: { + [DSH_PACKAGE]: release.version, + [DESKTOP_HOST_PACKAGE]: release.version, + }, + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } + writeJson(join(projectDir, 'package.json'), manifest) + writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) + writeJson(join(projectDir, 'desktop-release.json'), release) +} diff --git a/apps/desktop/src/release.ts b/apps/desktop/src/release.ts new file mode 100644 index 0000000000..f78d922e8c --- /dev/null +++ b/apps/desktop/src/release.ts @@ -0,0 +1,35 @@ +/** Immutable version identity shared by one Electron shell and its dsh seed. */ + +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. */ +export interface DesktopRelease { + readonly schemaVersion: 1 + /** Exact version used by both Electron and `@deepseek-ai/dsh`. */ + readonly version: string + readonly hostProtocolVersion: typeof DESKTOP_HOST_PROTOCOL_VERSION + readonly nodeVersion: string + readonly pnpmVersion: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Validate release data read from an installed or packaged filesystem resource. */ +export function parseDesktopRelease(value: unknown): DesktopRelease { + if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.version !== 'string' + || valid(value.version) === null || value.hostProtocolVersion !== DESKTOP_HOST_PROTOCOL_VERSION + || typeof value.nodeVersion !== 'string' || valid(value.nodeVersion) === null + || typeof value.pnpmVersion !== 'string' || valid(value.pnpmVersion) === null) { + throw new Error('dsh desktop: invalid desktop release metadata') + } + return { + schemaVersion: 1, + version: value.version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: value.nodeVersion, + pnpmVersion: value.pnpmVersion, + } +} diff --git a/apps/desktop/src/seed-store.ts b/apps/desktop/src/seed-store.ts new file mode 100644 index 0000000000..e4b4759fd1 --- /dev/null +++ b/apps/desktop/src/seed-store.ts @@ -0,0 +1,271 @@ +/** 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/single-instance.ts b/apps/desktop/src/single-instance.ts new file mode 100644 index 0000000000..7911b46c5b --- /dev/null +++ b/apps/desktop/src/single-instance.ts @@ -0,0 +1,26 @@ +/** Electron single-instance ownership before any Desktop profile lifecycle begins. */ + +/** Minimal Electron application operations needed for instance ownership. */ +export interface DesktopSingleInstanceApplication { + requestSingleInstanceLock(): boolean + quit(): void + on(event: 'second-instance', listener: () => void): unknown +} + +/** + * Claim the process-lifetime Desktop lock and route later launches to the owner. + * @param application - Electron application singleton. + * @param focusOwner - focus or recreate the primary window after a later launch. + * @returns true only in the process that may access the Desktop profile. + */ +export function claimDesktopSingleInstance( + application: DesktopSingleInstanceApplication, + focusOwner: () => void, +): boolean { + if (!application.requestSingleInstanceLock()) { + application.quit() + return false + } + application.on('second-instance', focusOwner) + return true +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts new file mode 100644 index 0000000000..c31dcc3093 --- /dev/null +++ b/apps/desktop/src/update-coordinator.ts @@ -0,0 +1,95 @@ +/** One Electron release stream for the version-bound shell and dsh seed. */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { app } from 'electron' +import electronUpdater, { type AppUpdater } from 'electron-updater' +import type { DesktopUpdateState } from './ipc.ts' +const { autoUpdater } = electronUpdater + +/** Checks, downloads, and installs one complete Desktop release. */ +export class DesktopUpdateCoordinator { + private availableVersion: string | undefined + private checkOperation: Promise | undefined + private installOperation: Promise | undefined + + /** + * @param publish - state sink for every desktop window. + * @param beforeRestart - stop application-owned processes before replacement. + * @param updater - Electron artifact updater; replaceable for tests. + * @param enabled - whether this packaged process carries updater configuration. + */ + constructor( + private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState, + private readonly beforeRestart: () => Promise = async () => {}, + private readonly updater: AppUpdater = autoUpdater, + private readonly enabled: () => boolean = () => ( + app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')) + ), + ) { + this.updater.autoDownload = false + this.updater.autoInstallOnAppQuit = false + } + + /** Check the configured Desktop release stream and retain an available version. */ + async check(): Promise { + if (this.installOperation !== undefined) return this.installOperation + if (this.checkOperation !== undefined) return this.checkOperation + this.checkOperation = this.doCheck().finally(() => { this.checkOperation = undefined }) + return this.checkOperation + } + + /** Wait for an in-flight check, then download and install its retained release. */ + async install(): Promise { + if (this.installOperation !== undefined) return this.installOperation + this.installOperation = (async () => { + await this.checkOperation + return this.doInstall() + })().finally(() => { this.installOperation = undefined }) + return this.installOperation + } + + private async doCheck(): Promise { + this.publish({ phase: 'checking' }) + try { + if (!this.enabled()) { + this.availableVersion = undefined + return this.publish({ phase: 'idle' }) + } + const result = await this.updater.checkForUpdates() + const version = result?.isUpdateAvailable === true ? result.updateInfo.version : undefined + this.availableVersion = version + return version === undefined + ? this.publish({ phase: 'idle' }) + : this.publish({ phase: 'available', version }) + } catch (error) { + this.availableVersion = undefined + return this.publish({ + phase: 'error', + message: error instanceof Error ? error.message : String(error), + }) + } + } + + private async doInstall(): Promise { + const version = this.availableVersion + if (version === undefined) { + throw new Error('desktop update: no verified update is available') + } + this.publish({ phase: 'installing', version }) + try { + await this.updater.downloadUpdate() + this.availableVersion = undefined + const ready = this.publish({ phase: 'ready', version }) + await this.beforeRestart() + this.updater.quitAndInstall(false, true) + return ready + } catch (error) { + return this.publish({ + phase: 'error', + version, + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/apps/desktop/tests/core-package-set.spec.ts b/apps/desktop/tests/core-package-set.spec.ts new file mode 100644 index 0000000000..0b28a2ec3d --- /dev/null +++ b/apps/desktop/tests/core-package-set.spec.ts @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + desktopCorePackageOverrides, + desktopDshPackageSpec, + parseDesktopCorePackageSet, + verifyDesktopCoreLockfile, + verifyDesktopCorePackageSet, + type DesktopCorePackageRecord, +} from '../src/core-package-set.ts' + +const roots: string[] = [] + +function record(name: string, file: string, body: Buffer, version = '1.2.3'): DesktopCorePackageRecord { + return { + name, + version, + file, + bytes: body.byteLength, + integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, + } +} + +function packageSetProject(): { + root: string + dsh: DesktopCorePackageRecord + base: DesktopCorePackageRecord + host: DesktopCorePackageRecord +} { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-package-set-')) + roots.push(root) + const packageDir = join(root, DESKTOP_PACKAGES_DIR) + mkdirSync(packageDir) + const dshBody = Buffer.from('dsh') + const baseBody = Buffer.from('base') + const hostBody = Buffer.from('host') + const dsh = record('@deepseek-ai/dsh', 'dsh.tgz', dshBody) + const base = record('@deepseek-ai/dsh-base', 'dsh-base.tgz', baseBody) + const host = record('@deepseek-ai/dsh-desktop-host', 'dsh-desktop-host.tgz', hostBody) + writeFileSync(join(packageDir, dsh.file), dshBody) + writeFileSync(join(packageDir, base.file), baseBody) + writeFileSync(join(packageDir, host.file), hostBody) + writeFileSync(join(root, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({ + schemaVersion: 1, + packages: [dsh, base, host], + })}\n`) + return { root, dsh, base, host } +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop core package set', () => { + it('pins the direct dsh dependency and every internal package to local tarballs', () => { + const { root } = packageSetProject() + const packageSet = verifyDesktopCorePackageSet(root, '1.2.3') + expect(desktopDshPackageSpec(packageSet)).toBe('file:./desktop-packages/dsh.tgz') + expect(desktopCorePackageOverrides(packageSet)).toEqual({ + '@deepseek-ai/dsh': 'file:./desktop-packages/dsh.tgz', + '@deepseek-ai/dsh-base': 'file:./desktop-packages/dsh-base.tgz', + '@deepseek-ai/dsh-desktop-host': 'file:./desktop-packages/dsh-desktop-host.tgz', + }) + }) + + it('rejects version drift, descriptor disorder, corruption, and extra files', () => { + const { root, dsh, base, host } = packageSetProject() + expect(() => verifyDesktopCorePackageSet(root, '2.0.0')).toThrow(/does not match Desktop/u) + expect(() => parseDesktopCorePackageSet({ + schemaVersion: 1, + packages: [dsh, base, { ...host, version: '2.0.0' }], + }, '1.2.3')).toThrow(/dsh-desktop-host@2\.0\.0 does not match Desktop 1\.2\.3/u) + expect(() => parseDesktopCorePackageSet({ schemaVersion: 1, packages: [base, dsh, host] })) + .toThrow(/sorted by name/u) + writeFileSync(join(root, DESKTOP_PACKAGES_DIR, dsh.file), 'changed') + expect(() => verifyDesktopCorePackageSet(root, '1.2.3')).toThrow(/integrity check failed/u) + writeFileSync(join(root, DESKTOP_PACKAGES_DIR, 'extra.tgz'), '') + expect(() => verifyDesktopCorePackageSet(root, '1.2.3')).toThrow(/does not match its descriptor/u) + }) + + it('rejects registry resolutions for names supplied by the local package set', () => { + const dsh = record('@deepseek-ai/dsh', 'dsh.tgz', Buffer.from('dsh')) + const host = record('@deepseek-ai/dsh-desktop-host', 'host.tgz', Buffer.from('host')) + const packageSet = parseDesktopCorePackageSet({ schemaVersion: 1, packages: [dsh, host] }) + expect(() => { + verifyDesktopCoreLockfile( + "packages:\n '@deepseek-ai/dsh@file:desktop-packages/dsh.tgz':\n resolution: {}\n", + packageSet, + ) + }).not.toThrow() + expect(() => { + verifyDesktopCoreLockfile( + "packages:\n '@deepseek-ai/dsh@1.2.3':\n resolution: {integrity: sha512-registry}\n", + packageSet, + ) + }).toThrow(/outside the local package set/u) + }) +}) diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts new file mode 100644 index 0000000000..71e368acda --- /dev/null +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + desktopBuildRecordFilename, + desktopUpdateMetadataFilename, + resolveDesktopAutoUpdateConfig, + resolveDesktopAutoUpdateEnvironment, + resolveDesktopAutoUpdateTarget, + resolveDesktopUploadConfig, +} from '../scripts/desktop-auto-update-environment.mjs' + +describe('desktop auto-update environment', () => { + it('defaults packages and uploads to the test deployment', () => { + expect(resolveDesktopAutoUpdateEnvironment({})).toBe('test') + expect(resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + }, 'darwin', 'arm64')).toEqual({ + environment: 'test', + target: 'mac-arm64', + origin: 'https://desktop-updates.example.com', + publicUrl: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + keyPrefix: '_/harness/desktop/stable/mac-arm64', + }) + expect(resolveDesktopUploadConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', + }, 'darwin', 'arm64')).toMatchObject({ + bucket: 'test-download-bucket', + secretIdEnvName: 'DOWNLOAD_TEST_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_TEST_COS_SECRET_KEY', + }) + }) + + it('selects the production URL for packages and bucket for uploads', () => { + expect(resolveDesktopAutoUpdateConfig({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }, 'win32', 'x64')).toMatchObject({ + environment: 'production', + target: 'win-x64', + publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/', + }) + expect(resolveDesktopUploadConfig({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket', + }, 'win32', 'x64')).toMatchObject({ + bucket: 'production-download-bucket', + secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', + secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', + }) + }) + + it('requires the selected deployment origin for packages and bucket only for uploads', () => { + expect(() => resolveDesktopAutoUpdateConfig({}, 'darwin', 'arm64')) + .toThrow(/DOWNLOAD_TEST_ORIGIN/u) + expect(resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + }, 'darwin', 'arm64').publicUrl).toContain('/mac-arm64/') + expect(() => resolveDesktopUploadConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + }, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_COS_BUCKET/u) + expect(() => resolveDesktopUploadConfig({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }, 'win32', 'x64')).toThrow(/DOWNLOAD_PROD_COS_BUCKET/u) + }) + + it('rejects a test download URL that is not an HTTPS origin', () => { + expect(() => resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/releases', + }, 'darwin', 'arm64')).toThrow(/HTTPS origin without a path/u) + expect(() => resolveDesktopAutoUpdateConfig({ + DOWNLOAD_TEST_ORIGIN: 'http://desktop-updates.example.com', + }, 'darwin', 'arm64')).toThrow(/HTTPS origin/u) + }) + + it('rejects unknown deployments and targets', () => { + expect(() => resolveDesktopAutoUpdateEnvironment({ + DSH_DESKTOP_AUTO_UPDATE_ENV: 'staging', + })).toThrow(/test.*production/u) + expect(() => resolveDesktopAutoUpdateTarget('linux', 'x64')).toThrow(/unsupported target/u) + expect(() => desktopBuildRecordFilename('linux-x64' as 'mac-arm64')).toThrow(/unsupported target/u) + }) + + it('matches electron-builder channel metadata names to the Desktop version', () => { + expect(desktopUpdateMetadataFilename('1.2.3', 'darwin')).toBe('latest-mac.yml') + expect(desktopUpdateMetadataFilename('1.2.3-alpha.4', 'darwin')).toBe('alpha-mac.yml') + expect(desktopUpdateMetadataFilename('1.2.3-beta.2', 'win32')).toBe('beta.yml') + expect(() => desktopUpdateMetadataFilename('not-semver', 'darwin')).toThrow(/invalid Desktop version/u) + expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u) + }) +}) diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts new file mode 100644 index 0000000000..ac1618baef --- /dev/null +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -0,0 +1,50 @@ +import { join, sep } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + desktopTargetBuildPaths, + resolveDesktopBuildTarget, +} from '../scripts/desktop-build-paths.mjs' + +describe('desktop build paths', () => { + it('isolates every mutable build directory by complete target', () => { + const arm64 = desktopTargetBuildPaths('mac-arm64') + const x64 = desktopTargetBuildPaths('mac-x64') + const windows = desktopTargetBuildPaths('win-x64') + const mutableKeys = [ + 'root', + 'artifacts', + 'runtime', + 'packageSet', + 'seed', + 'seedPnpm', + 'nodeExtract', + 'packedDsh', + 'packedVendor', + 'packedLandlock', + ] as const + + for (const key of mutableKeys) { + 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(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) + }) + + it('shares only the immutable upstream download cache', () => { + const arm64 = desktopTargetBuildPaths('mac-arm64') + const x64 = desktopTargetBuildPaths('mac-x64') + expect(arm64.downloads).toBe(x64.downloads) + expect(arm64.downloads).not.toContain(`${sep}targets${sep}`) + }) + + it('resolves environment overrides and rejects unsupported targets', () => { + expect(resolveDesktopBuildTarget({ + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', + DSH_DESKTOP_TARGET_ARCH: 'x64', + }, 'darwin', 'arm64')).toBe('mac-x64') + expect(resolveDesktopBuildTarget({}, 'win32', 'x64')).toBe('win-x64') + expect(() => resolveDesktopBuildTarget({}, 'linux', 'x64')).toThrow(/unsupported target/u) + expect(() => desktopTargetBuildPaths('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u) + }) +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts new file mode 100644 index 0000000000..4a34b0afbe --- /dev/null +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { desktopUpdateMetadataFilename } from '../scripts/desktop-auto-update-environment.mjs' +import type { DesktopPackageTargetName } from '../scripts/package-target.ts' + +const temporaryDirectories: string[] = [] +const TEST_ORIGIN = 'https://desktop-updates.example.com' +const TEST_BUCKET = 'test-download-bucket' +const PRODUCTION_BUCKET = 'production-download-bucket' + +interface Fixture { + readonly repositoryRoot: string + readonly appRoot: string + readonly artifactsRoot: string + readonly environment: NodeJS.ProcessEnv +} + +function digest(contents: string): string { + return createHash('sha512').update(contents).digest('base64') +} + +async function fixture( + target: DesktopPackageTargetName, + version = '1.2.3', + environment: 'test' | 'production' = 'test', +): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) + temporaryDirectories.push(root) + const repositoryRoot = join(root, 'repository') + const appRoot = join(repositoryRoot, 'apps', 'desktop') + const artifactsRoot = join(appRoot, '.desktop-build', 'artifacts') + await mkdir(artifactsRoot, { recursive: true }) + await writeFile(join(repositoryRoot, 'package.json'), `${JSON.stringify({ version })}\n`) + await writeFile(join(appRoot, 'package.json'), `${JSON.stringify({ version })}\n`) + + const [os, arch] = target.split('-') as ['mac' | 'win', 'arm64' | 'x64'] + const base = `deepseek-harness-${version}-${os}-${arch}` + const origin = environment === 'test' + ? TEST_ORIGIN + : 'https://download.deepseek.com' + await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({ + schemaVersion: 1, + target, + version, + environment, + publicUrl: `${origin}/_/harness/desktop/stable/${target}/`, + })}\n`) + + if (os === 'mac') { + const zip = 'signed macOS ZIP fixture' + await writeFile(join(artifactsRoot, `${base}.zip`), zip) + await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap') + await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ + version, + files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], + })}\n`) + } + else { + const executable = 'signed NSIS executable fixture' + await writeFile(join(artifactsRoot, `${base}.exe`), executable) + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ + version, + files: [{ + url: `${base}.exe`, + size: Buffer.byteLength(executable), + sha512: digest(executable), + blockMapSize: 128, + }], + })}\n`) + } + return { + repositoryRoot, + appRoot, + artifactsRoot, + environment: environment === 'test' + ? { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', + DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, + } + : { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET, + }, + } +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async path => rm(path, { + recursive: true, + force: true, + }))) +}) + +describe('desktop upload plan', () => { + it('validates macOS artifacts and puts channel metadata last', async () => { + const paths = await fixture('mac-arm64') + const plan = await createDesktopUploadPlan('mac-arm64', paths) + expect(plan).toMatchObject({ + environment: 'test', + version: '1.2.3', + publicUrl: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + bucket: TEST_BUCKET, + }) + expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ + 'deepseek-harness-1.2.3-mac-arm64.dmg', + 'deepseek-harness-1.2.3-mac-arm64.zip', + 'deepseek-harness-1.2.3-mac-arm64.zip.blockmap', + 'latest-mac.yml', + ]) + expect(plan.artifacts.at(-1)).toMatchObject({ + channelMetadata: true, + cacheControl: 'no-cache', + }) + }) + + it('uploads the prerelease channel metadata emitted by electron-builder', async () => { + const paths = await fixture('mac-arm64', '1.2.3-alpha.4') + const plan = await createDesktopUploadPlan('mac-arm64', paths) + expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ + 'deepseek-harness-1.2.3-alpha.4-mac-arm64.dmg', + 'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip', + 'deepseek-harness-1.2.3-alpha.4-mac-arm64.zip.blockmap', + 'alpha-mac.yml', + ]) + }) + + it('validates the Windows installer with its embedded blockmap and production destination', async () => { + const paths = await fixture('win-x64', '2.0.0', 'production') + const plan = await createDesktopUploadPlan('win-x64', paths) + expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ + 'deepseek-harness-2.0.0-win-x64.exe', + 'latest.yml', + ]) + expect(plan).toMatchObject({ + publicUrl: 'https://download.deepseek.com/_/harness/desktop/stable/win-x64/', + bucket: PRODUCTION_BUCKET, + }) + }) + + it('rejects Windows metadata without an embedded blockmap size', async () => { + const paths = await fixture('win-x64') + const executable = 'signed NSIS executable fixture' + await writeFile(join(paths.artifactsRoot, 'latest.yml'), `${JSON.stringify({ + version: '1.2.3', + files: [{ + url: 'deepseek-harness-1.2.3-win-x64.exe', + size: Buffer.byteLength(executable), + sha512: digest(executable), + }], + })}\n`) + await expect(createDesktopUploadPlan('win-x64', paths)).rejects.toThrow(/blockMapSize/u) + }) + + it('rejects a completed build from another dsh version or deployment', async () => { + const paths = await fixture('mac-x64') + await writeFile(join(paths.repositoryRoot, 'package.json'), '{"version":"1.2.4"}\n') + await writeFile(join(paths.appRoot, 'package.json'), '{"version":"1.2.4"}\n') + await expect(createDesktopUploadPlan('mac-x64', paths)).rejects.toThrow(/completion record.*1\.2\.4/u) + + const productionPaths = await fixture('mac-x64', '1.2.3', 'production') + await expect(createDesktopUploadPlan('mac-x64', { + ...productionPaths, + environment: { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', + DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, + }, + })).rejects.toThrow(/completion record.*test/u) + }) + + it('rejects stale architecture metadata and modified updater bytes', async () => { + const paths = await fixture('mac-arm64') + const metadataPath = join(paths.artifactsRoot, 'latest-mac.yml') + const zipPath = join(paths.artifactsRoot, 'deepseek-harness-1.2.3-mac-arm64.zip') + await writeFile(zipPath, 'modified') + await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/size.*metadata/u) + + const x64 = 'wrong architecture' + await writeFile(metadataPath, `${JSON.stringify({ + version: '1.2.3', + files: [{ + url: 'deepseek-harness-1.2.3-mac-x64.zip', + size: Buffer.byteLength(x64), + sha512: digest(x64), + }], + })}\n`) + await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/mac-arm64\.zip/u) + }) +}) diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts new file mode 100644 index 0000000000..dbdfa09347 --- /dev/null +++ b/apps/desktop/tests/development-project.spec.ts @@ -0,0 +1,89 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prepareDevelopmentProject } from '../scripts/development-project.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import type { DesktopRelease } from '../src/release.ts' + +const roots: string[] = [] + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-development-test-')) + roots.push(root) + return root +} + +function release(version = '1.2.3'): DesktopRelease { + return { + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + } +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop development project', () => { + it('projects the built dsh and Desktop Host applications with their dependency graph', () => { + const root = temporaryRoot() + const cli = join(root, 'apps', 'cli') + const host = join(root, 'apps', 'desktop-host') + const dependencies = join(root, 'workspace-dependencies') + mkdirSync(join(cli, 'lib'), { recursive: true }) + mkdirSync(join(host, 'lib'), { recursive: true }) + mkdirSync(join(dependencies, '@scope'), { recursive: true }) + mkdirSync(join(dependencies, '@deepseek-ai', 'dsh'), { recursive: true }) + writeFileSync(join(cli, 'package.json'), '{"name":"@deepseek-ai/dsh","version":"1.2.3"}\n') + writeFileSync(join(host, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","version":"1.2.3"}\n') + writeFileSync(join(host, 'lib', 'index.js'), '') + writeFileSync(join(dependencies, '@deepseek-ai', 'dsh', 'package.json'), '{}\n') + mkdirSync(join(dependencies, 'plain-dependency')) + writeFileSync(join(dependencies, 'plain-dependency', 'package.json'), '{}\n') + mkdirSync(join(dependencies, '@scope', 'dependency')) + writeFileSync(join(dependencies, '@scope', 'dependency', 'package.json'), '{}\n') + + const project = prepareDevelopmentProject({ + projectDir: join(root, 'development'), + cliDir: cli, + hostDir: host, + dependencyDir: dependencies, + release: release(), + }) + expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli)) + expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host)) + expect(realpathSync(join(project, 'node_modules', 'plain-dependency'))) + .toBe(realpathSync(join(dependencies, 'plain-dependency'))) + expect(realpathSync(join(project, 'node_modules', '@scope', 'dependency'))) + .toBe(realpathSync(join(dependencies, '@scope', 'dependency'))) + const manifest = JSON.parse(readFileSync(join(project, 'package.json'), 'utf8')) as { + dependencies: Record + } + expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3') + expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3') + }) + + it('rejects a CLI package from another release', () => { + const root = temporaryRoot() + const cli = join(root, 'apps', 'cli') + const host = join(root, 'apps', 'desktop-host') + const dependencies = join(root, 'workspace-dependencies') + mkdirSync(join(cli, 'lib'), { recursive: true }) + mkdirSync(join(host, 'lib'), { recursive: true }) + mkdirSync(dependencies, { recursive: true }) + writeFileSync(join(cli, 'package.json'), '{"name":"@deepseek-ai/dsh","version":"2.0.0"}\n') + writeFileSync(join(host, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","version":"1.2.3"}\n') + writeFileSync(join(host, 'lib', 'index.js'), '') + expect(() => prepareDevelopmentProject({ + projectDir: join(root, 'development'), + cliDir: cli, + hostDir: host, + dependencyDir: dependencies, + release: release(), + })).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u) + }) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts new file mode 100644 index 0000000000..d3a08d78e0 --- /dev/null +++ b/apps/desktop/tests/host-process.spec.ts @@ -0,0 +1,216 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { DesktopHostProcess } from '../src/host-process.ts' + +const roots: string[] = [] + +const HOST_WIRE = ` +import { closeSync, createReadStream, createWriteStream } from 'node:fs' +const requestPipe = createReadStream('', { fd: 3, autoClose: false }) +const responsePipe = createWriteStream('', { fd: 4, autoClose: false }) +const MAGIC = 0x44534833 +const HEADER = 13 +function responseFrame(type, streamId, payload = Buffer.alloc(0)) { + const frame = Buffer.allocUnsafe(HEADER + payload.length) + frame.writeUInt32BE(MAGIC, 0) + frame.writeUInt8(type, 4) + frame.writeUInt32BE(streamId, 5) + frame.writeUInt32BE(payload.length, 9) + payload.copy(frame, HEADER) + return frame +} +function responseStart(streamId, options = {}) { + const value = { status: options.status ?? 200, headers: options.headers ?? [], hasBody: options.hasBody ?? true } + responsePipe.write(responseFrame(1, streamId, Buffer.from(JSON.stringify(value)))) +} +function responseData(streamId, data) { + responsePipe.write(responseFrame(2, streamId, Buffer.from(data))) +} +function responseEnd(streamId) { responsePipe.write(responseFrame(3, streamId)) } +function responseError(streamId, message) { + responsePipe.write(responseFrame(4, streamId, Buffer.from(JSON.stringify({ message })))) +} +let requestBuffer = Buffer.alloc(0) +requestPipe.on('data', chunk => { + requestBuffer = requestBuffer.length === 0 ? chunk : Buffer.concat([requestBuffer, chunk]) + while (requestBuffer.length >= HEADER) { + if (requestBuffer.readUInt32BE(0) !== MAGIC) throw new Error('invalid request marker') + const type = requestBuffer.readUInt8(4) + const streamId = requestBuffer.readUInt32BE(5) + const length = requestBuffer.readUInt32BE(9) + if (requestBuffer.length < HEADER + length) return + const payload = requestBuffer.subarray(HEADER, HEADER + length) + requestBuffer = requestBuffer.subarray(HEADER + length) + onRequestFrame({ type, streamId, payload }) + } +}) +process.on('message', message => { + if (message.type === 'shutdown') { + requestPipe.destroy() + closeSync(3) + responsePipe.end(() => { + responsePipe.destroy() + closeSync(4) + process.disconnect() + process.exitCode = 0 + }) + } +}) +` + +function projectWithHost(source: string): string { + const project = mkdtempSync(join(tmpdir(), 'dsh-desktop-host-test-')) + roots.push(project) + const packageRoot = join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host') + mkdirSync(join(packageRoot, 'lib'), { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), '{"name":"@deepseek-ai/dsh-desktop-host","type":"module"}\n') + writeFileSync(join(packageRoot, 'lib', 'index.js'), `${HOST_WIRE}\n${source}`) + return project +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('desktop host process', () => { + it('carries raw request and response bytes and shuts the child down cleanly', async () => { + const project = projectWithHost(` +const bodies = new Map() +process.send({ type: 'ready', protocolVersion: 3, dshVersion: process.env.NODE_OPTIONS ?? 'clean' }) +function onRequestFrame(frame) { + if (frame.type === 1) { + const request = JSON.parse(frame.payload) + bodies.set(frame.streamId, Buffer.alloc(0)) + if (!request.hasBody) answer(frame.streamId) + } else if (frame.type === 2) { + bodies.set(frame.streamId, Buffer.concat([bodies.get(frame.streamId), frame.payload])) + } else if (frame.type === 3) { + answer(frame.streamId) + } +} +function answer(streamId) { + responseStart(streamId, { headers: [['content-type', 'text/plain']] }) + responseData(streamId, Buffer.concat([Buffer.from('desktop:'), bodies.get(streamId)])) + responseEnd(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) + try { + await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' }) + const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' })) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('desktop:request') + await expect(host.stop()).resolves.toBeUndefined() + } finally { + if (previous === undefined) delete process.env.NODE_OPTIONS + else process.env.NODE_OPTIONS = previous + await host.stop().catch(() => undefined) + } + }) + + it('streams a large binary response in bounded raw frames', async () => { + const size = 2 * 1024 * 1024 + const project = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'large-response' }) +function onRequestFrame(frame) { + if (frame.type !== 1) return + responseStart(frame.streamId) + const bytes = Buffer.alloc(${String(64 * 1024)}, 97) + for (let offset = 0; offset < ${String(size)}; offset += bytes.length) responseData(frame.streamId, bytes) + responseEnd(frame.streamId) +} +`) + const host = new DesktopHostProcess(process.execPath, project) + try { + const response = await host.fetch(new Request('dsh-app://app/large')) + const body = new Uint8Array(await response.arrayBuffer()) + expect(body).toHaveLength(size) + expect(body[0]).toBe(97) + expect(body.at(-1)).toBe(97) + } finally { + await host.stop().catch(() => undefined) + } + }) + + it('stops an unfinished upload when the Host completes its response early', async () => { + const project = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'early-response' }) +function onRequestFrame(frame) { + if (frame.type !== 2) return + responseStart(frame.streamId) + responseData(frame.streamId, 'accepted') + responseEnd(frame.streamId) +} +`) + let canceled = false + const body = new ReadableStream({ + start(controller) { controller.enqueue(Buffer.from('first')) }, + cancel() { canceled = true }, + }) + const host = new DesktopHostProcess(process.execPath, project) + try { + const request = new Request('dsh-app://app/early', { + method: 'POST', + body, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) + const response = await host.fetch(request) + await expect(response.text()).resolves.toBe('accepted') + await expect.poll(() => canceled).toBe(true) + } finally { + await host.stop().catch(() => undefined) + } + }) + + it('ignores a response end that arrives after the renderer cancels its stream', async () => { + const project = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'cancel-race' }) +const urls = new Map() +function onRequestFrame(frame) { + if (frame.type === 1) { + const request = JSON.parse(frame.payload) + urls.set(frame.streamId, request.url) + responseStart(frame.streamId) + if (request.url.endsWith('/after')) { + responseData(frame.streamId, 'alive') + responseEnd(frame.streamId) + } + } else if (frame.type === 4 && urls.get(frame.streamId).endsWith('/cancel')) { + responseEnd(frame.streamId) + } +} +`) + const host = new DesktopHostProcess(process.execPath, project) + try { + const canceled = await host.fetch(new Request('dsh-app://app/cancel')) + await canceled.body?.cancel() + await new Promise(resolve => setTimeout(resolve, 25)) + const after = await host.fetch(new Request('dsh-app://app/after')) + await expect(after.text()).resolves.toBe('alive') + } finally { + await host.stop().catch(() => undefined) + } + }) + + it('rejects invalid response framing and a clean exit before readiness', async () => { + const invalid = new DesktopHostProcess(process.execPath, projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' }) +function onRequestFrame(frame) { + if (frame.type === 1) responsePipe.write(Buffer.alloc(13)) +} +`)) + 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) + + const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(` +function onRequestFrame() {} +process.exit(0) +`)) + await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u) + }) +}) diff --git a/apps/desktop/tests/host-protocol.spec.ts b/apps/desktop/tests/host-protocol.spec.ts new file mode 100644 index 0000000000..b0455f2c6f --- /dev/null +++ b/apps/desktop/tests/host-protocol.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + DesktopHostRequestDecoder, + encodeDesktopResponseData, + encodeDesktopResponseEnd, + encodeDesktopResponseError, + encodeDesktopResponseStart, +} from '../../desktop-host/src/wire.ts' +import { + DesktopHostResponseDecoder, + encodeDesktopRequestCancel, + encodeDesktopRequestData, + encodeDesktopRequestEnd, + encodeDesktopRequestStart, +} from '../src/host-protocol.ts' + +function decodeInPieces(bytes: Buffer, push: (chunk: Buffer) => readonly T[]): T[] { + const values: T[] = [] + for (let offset = 0; offset < bytes.byteLength; offset += 7) { + values.push(...push(bytes.subarray(offset, offset + 7))) + } + return values +} + +describe('desktop Host pipe protocol', () => { + it('keeps Electron request frames compatible with the installed Host decoder', () => { + const decoder = new DesktopHostRequestDecoder() + const bytes = Buffer.concat([ + encodeDesktopRequestStart(7, { + url: 'dsh-app://app/api/session', + method: 'POST', + headers: [['content-type', 'application/json']], + hasBody: true, + }), + encodeDesktopRequestData(7, Buffer.from('{"ok":true}')), + encodeDesktopRequestEnd(7), + encodeDesktopRequestCancel(7), + ]) + + expect(decodeInPieces(bytes, chunk => decoder.push(chunk))).toEqual([ + { + type: 'start', + streamId: 7, + url: 'dsh-app://app/api/session', + method: 'POST', + headers: [['content-type', 'application/json']], + hasBody: true, + }, + { type: 'data', streamId: 7, data: Buffer.from('{"ok":true}') }, + { type: 'end', streamId: 7 }, + { type: 'cancel', streamId: 7 }, + ]) + expect(() => { decoder.finish() }).not.toThrow() + }) + + it('keeps Host response frames compatible with the Electron decoder', () => { + const decoder = new DesktopHostResponseDecoder() + const bytes = Buffer.concat([ + encodeDesktopResponseStart(9, { + status: 201, + headers: [['content-type', 'application/octet-stream']], + hasBody: true, + }), + encodeDesktopResponseData(9, Buffer.from([0, 1, 2, 255])), + encodeDesktopResponseEnd(9), + encodeDesktopResponseError(10, 'failed'), + ]) + + expect(decodeInPieces(bytes, chunk => decoder.push(chunk))).toEqual([ + { + type: 'start', + streamId: 9, + status: 201, + headers: [['content-type', 'application/octet-stream']], + hasBody: true, + }, + { type: 'data', streamId: 9, data: Buffer.from([0, 1, 2, 255]) }, + { type: 'end', streamId: 9 }, + { type: 'error', streamId: 10, message: 'failed' }, + ]) + expect(() => { decoder.finish() }).not.toThrow() + }) + + it('rejects a corrupt marker and truncated EOF on both directions', () => { + const request = new DesktopHostRequestDecoder() + const response = new DesktopHostResponseDecoder() + expect(() => request.push(Buffer.alloc(13))).toThrow(/request frame marker/u) + expect(() => response.push(Buffer.alloc(13))).toThrow(/response frame marker/u) + + const partialRequest = new DesktopHostRequestDecoder() + partialRequest.push(encodeDesktopRequestEnd(1).subarray(0, 5)) + expect(() => { partialRequest.finish() }).toThrow(/ended inside a frame/u) + + const partialResponse = new DesktopHostResponseDecoder() + partialResponse.push(encodeDesktopResponseEnd(1).subarray(0, 5)) + expect(() => { partialResponse.finish() }).toThrow(/ended inside a frame/u) + }) +}) diff --git a/apps/desktop/tests/locale.spec.ts b/apps/desktop/tests/locale.spec.ts new file mode 100644 index 0000000000..de7a7a7048 --- /dev/null +++ b/apps/desktop/tests/locale.spec.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { en, formatDesktopMessage, resolveDesktopLocale, zh } from '../src/locale.ts' + +describe('desktop locale dictionaries', () => { + it('ships the same key set in English and Chinese', () => { + expect(Object.keys(zh)).toEqual(Object.keys(en)) + expect(resolveDesktopLocale('zh-Hans-CN')).toEqual({ id: 'zh-CN', messages: zh }) + expect(resolveDesktopLocale('en-US')).toEqual({ id: 'en', messages: en }) + expect(resolveDesktopLocale('fr-FR')).toEqual({ id: 'en', messages: en }) + }) + + it('formats named values without consuming unknown placeholders', () => { + expect(formatDesktopMessage('{name}@{version} {missing}', { name: 'plugin', version: '1.2.3' })) + .toBe('plugin@1.2.3 {missing}') + }) + + it('keeps visible plugin-manager HTML copy in the locale dictionaries', () => { + const html = readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8') + const staticText = [...html.matchAll(/>([^<]*\p{L}[^<]*) match[1]?.trim()) + expect(staticText).toEqual([]) + }) +}) diff --git a/apps/desktop/tests/macos-seed-store.spec.ts b/apps/desktop/tests/macos-seed-store.spec.ts new file mode 100644 index 0000000000..2155389262 --- /dev/null +++ b/apps/desktop/tests/macos-seed-store.spec.ts @@ -0,0 +1,245 @@ +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 new file mode 100644 index 0000000000..827de532e9 --- /dev/null +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -0,0 +1,171 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NotarizeOptions } from '@electron/notarize' +import { + resolveDesktopAppId, + resolveMacOSNotarizationEnvironment, + resolveMacOSSigningEnvironment, +} from '../scripts/desktop-release-environment.mjs' +import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs' +import { + assertMacOSSeedSignatureDetails, + assertMacOSSignatureDetails, +} from '../scripts/verify-macos-signature.mjs' + +const RELEASE_ENVIRONMENT = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', + DSH_DESKTOP_TARGET_ARCH: 'arm64', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_API_KEY: '/private/credentials/AuthKey_TEST123456.p8', + APPLE_API_KEY_ID: 'TEST123456', + APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', +} + +function portablePath(value: string): string { + return value.replaceAll('\\', '/') +} + +describe('desktop macOS release signature', () => { + beforeAll(() => { + for (const [name, value] of Object.entries(RELEASE_ENVIRONMENT)) vi.stubEnv(name, value) + }) + + afterAll(() => { + vi.unstubAllEnvs() + }) + + it('loads release identifiers from the environment and requires code signing', async () => { + 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[0]?.to).toBe('runtime') + expect(config.extraResources[1]?.to).toBe('seed') + 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(config).toMatchObject({ + appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + mac: { + identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, + forceCodeSigning: true, + notarize: true, + }, + dmg: { + sign: true, + writeUpdateInfo: false, + }, + publish: [{ + provider: 'generic', + url: 'https://desktop-updates.example.com/_/harness/desktop/stable/mac-arm64/', + }], + }) + expect(typeof config.artifactBuildCompleted).toBe('function') + }) + + it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + expect(() => createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_TARGET_PLATFORM: 'win32', + }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) + }) + + it('accepts the configured authority and team', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + expect(() => { + assertMacOSSignatureDetails([ + `Authority=Developer ID Application: ${expected.signingIdentity}`, + `TeamIdentifier=${expected.teamId}`, + ].join('\n'), expected) + }).not.toThrow() + }) + + it('requires a secure timestamp and hardened runtime for seed code', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + const details = [ + `Authority=Developer ID Application: ${expected.signingIdentity}`, + `TeamIdentifier=${expected.teamId}`, + '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(() => { + assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) + }).toThrow(/secure timestamp/u) + expect(() => { + assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) + }).toThrow(/hardened runtime/u) + }) + + it('rejects another developer identity', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + expect(() => { + assertMacOSSignatureDetails([ + 'Authority=Developer ID Application: Other Company (OTHERID123)', + 'TeamIdentifier=OTHERID123', + ].join('\n'), expected) + }).toThrow(/release identity/u) + }) + + it('rejects an unexpected team even when the authority is present', () => { + const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) + expect(() => { + assertMacOSSignatureDetails([ + `Authority=Developer ID Application: ${expected.signingIdentity}`, + 'TeamIdentifier=OTHERID123', + ].join('\n'), expected) + }).toThrow(`TeamIdentifier=${expected.teamId}`) + }) + + it('rejects missing and malformed release identifiers', () => { + expect(() => resolveDesktopAppId({})).toThrow(/DSH_DESKTOP_APP_ID/u) + expect(() => resolveDesktopAppId({ DSH_DESKTOP_APP_ID: 'not-a-bundle-id' })).toThrow(/reverse-DNS/u) + expect(() => resolveMacOSSigningEnvironment({})).toThrow(/DSH_DESKTOP_MACOS_SIGNING_IDENTITY/u) + expect(() => resolveMacOSSigningEnvironment({ + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Developer ID Application: Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + })).toThrow(/must omit/u) + expect(() => resolveMacOSSigningEnvironment({ + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'short', + })).toThrow(/10 uppercase/u) + }) + + it('requires one complete notarization credential strategy', () => { + expect(resolveMacOSNotarizationEnvironment(RELEASE_ENVIRONMENT)).toEqual({ + appleApiKey: RELEASE_ENVIRONMENT.APPLE_API_KEY, + appleApiKeyId: RELEASE_ENVIRONMENT.APPLE_API_KEY_ID, + appleApiIssuer: RELEASE_ENVIRONMENT.APPLE_API_ISSUER, + }) + expect(resolveMacOSNotarizationEnvironment({ + APPLE_KEYCHAIN_PROFILE: 'dsh-notary', + })).toEqual({ keychainProfile: 'dsh-notary' }) + expect(() => resolveMacOSNotarizationEnvironment({})).toThrow(/macOS packaging requires/u) + expect(() => resolveMacOSNotarizationEnvironment({ APPLE_API_KEY: '/tmp/key.p8' })).toThrow(/APPLE_API_KEY_ID/u) + }) + + it('notarizes and qualifies a DMG before electron-builder publishes it', async () => { + const submitted: string[] = [] + const submit = vi.fn(async (options: NotarizeOptions) => { submitted.push(options.appPath) }) + const verified: string[] = [] + const verify = vi.fn((path: string) => { verified.push(path) }) + await notarizeMacOSDiskImageArtifact( + { file: '/tmp/release.dmg' }, + RELEASE_ENVIRONMENT, + resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT), + submit, + verify, + ) + await notarizeMacOSDiskImageArtifact( + { file: '/tmp/release.zip' }, + RELEASE_ENVIRONMENT, + resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT), + submit, + verify, + ) + expect(submitted).toEqual(['/tmp/release.dmg']) + expect(verified).toEqual(['/tmp/release.dmg']) + }) +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts new file mode 100644 index 0000000000..e822f212f5 --- /dev/null +++ b/apps/desktop/tests/package-target.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + desktopElectronBuilderArguments, + parseDesktopPackageInvocation, + resolveDesktopPackageTarget, + withoutDesktopUploadCredentials, + withoutWindowsSigningEnvironment, +} from '../scripts/package-target.ts' + +describe('desktop package target', () => { + it('selects matching runtime and electron-builder architectures', () => { + expect(resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')).toMatchObject({ + platform: 'darwin', arch: 'arm64', builderPlatform: '--mac', builderArch: '--arm64', + }) + expect(resolveDesktopPackageTarget('mac-x64', 'darwin', 'x64')).toMatchObject({ + platform: 'darwin', arch: 'x64', builderPlatform: '--mac', builderArch: '--x64', + }) + expect(resolveDesktopPackageTarget('win-x64', 'win32', 'x64')).toMatchObject({ + platform: 'win32', arch: 'x64', builderPlatform: '--win', builderArch: '--x64', + }) + }) + + it('allows an Apple Silicon host to build the Intel target through Rosetta', () => { + expect(resolveDesktopPackageTarget('mac-x64', 'darwin', 'arm64').arch).toBe('x64') + }) + + it('rejects unsupported targets and hosts before building', () => { + expect(() => resolveDesktopPackageTarget('linux-x64', 'linux', 'x64')).toThrow(/unsupported target/u) + expect(() => resolveDesktopPackageTarget('win-x64', 'darwin', 'arm64')).toThrow(/Windows x64/u) + expect(() => resolveDesktopPackageTarget('mac-arm64', 'darwin', 'x64')).toThrow(/Apple Silicon/u) + expect(() => resolveDesktopPackageTarget('mac-arm64', 'linux', 'arm64')).toThrow(/macOS/u) + expect(() => resolveDesktopPackageTarget('mac-x64', 'darwin', 'ppc64')).toThrow(/Rosetta/u) + }) + + it('parses installer and unpacked-directory invocations', () => { + expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').directory).toBe(false) + expect(parseDesktopPackageInvocation(['mac-arm64', '--dir'], 'darwin', 'arm64').directory).toBe(true) + expect(parseDesktopPackageInvocation([], 'darwin', 'arm64').target.name).toBe('mac-arm64') + expect(parseDesktopPackageInvocation(['--prepare-only'], 'darwin', 'arm64').prepareOnly).toBe(true) + expect(() => parseDesktopPackageInvocation(['mac-arm64', 'mac-x64'], 'darwin', 'arm64')) + .toThrow(/at most one target/u) + }) + + it('keeps electron-builder publishing disabled for the separate validated upload', () => { + const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64') + expect(desktopElectronBuilderArguments(target, false)).toEqual([ + 'exec', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--mac', + '--arm64', + '--publish', + 'never', + ]) + expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') + }) + + it('keeps Windows signing fields out of build and seed preparation subprocesses', () => { + expect(withoutWindowsSigningEnvironment({ + DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'container', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + })).toEqual({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' }) + }) + + it('keeps COS credentials out of every packaging subprocess', () => { + expect(withoutDesktopUploadCredentials({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', + DOWNLOAD_TEST_COS_SECRET_ID: 'test-id', + DOWNLOAD_TEST_COS_SECRET_KEY: 'test-key', + DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'production-id', + DOWNLOAD_PROD_COS_SECRET_KEY: 'production-key', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + })).toEqual({ + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', + DOWNLOAD_PROD_COS_BUCKET: 'production-download-bucket', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + }) + }) +}) diff --git a/apps/desktop/tests/prepare-package-set.spec.ts b/apps/desktop/tests/prepare-package-set.spec.ts new file mode 100644 index 0000000000..ded24ceeea --- /dev/null +++ b/apps/desktop/tests/prepare-package-set.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + assertDesktopHostPackageFiles, + selectDesktopPackageClosure, + type PackedDesktopPackage, +} from '../scripts/prepare-package-set.ts' + +function packed(name: string, manifest: Record = {}): PackedDesktopPackage { + return { tarball: `${name}.tgz`, manifest: { name, version: '1.0.0', ...manifest } } +} + +describe('desktop package-set selection', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('does not select a packaging target when imported as a library', async () => { + vi.stubEnv('DSH_DESKTOP_TARGET_PLATFORM', 'linux') + vi.stubEnv('DSH_DESKTOP_TARGET_ARCH', 'x64') + vi.resetModules() + await expect(import('../scripts/prepare-package-set.ts')).resolves.toHaveProperty('prepareDesktopPackageSet') + }) + + it('includes only the available internal production closure', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { '@deepseek-ai/dsh-base': '^1.0.0', external: '^2.0.0' }, + optionalDependencies: { '@deepseek-ai/platform-package': '1.0.0', '@deepseek-ai/missing-platform': '1.0.0' }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', { + dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, + })], + ['@deepseek-ai/dsh-base', packed('@deepseek-ai/dsh-base', { + peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, + })], + ['@deepseek-ai/cordis', packed('@deepseek-ai/cordis')], + ['@deepseek-ai/platform-package', packed('@deepseek-ai/platform-package')], + ['@deepseek-ai/unused', packed('@deepseek-ai/unused')], + ]) + expect(selectDesktopPackageClosure(available).map(entry => entry.manifest.name)).toEqual([ + '@deepseek-ai/cordis', + '@deepseek-ai/dsh', + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-desktop-host', + '@deepseek-ai/platform-package', + ]) + }) + + it('rejects a required internal package absent from the packed release inputs', () => { + const available = new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh', { + dependencies: { '@deepseek-ai/dsh-base': '^1.0.0' }, + })], + ['@deepseek-ai/dsh-desktop-host', packed('@deepseek-ai/dsh-desktop-host', { + dependencies: { '@deepseek-ai/dsh': '^1.0.0' }, + })], + ]) + expect(() => selectDesktopPackageClosure(available)).toThrow(/unpacked internal package/u) + expect(() => selectDesktopPackageClosure(new Map([ + ['@deepseek-ai/dsh', packed('@deepseek-ai/dsh')], + ]))).toThrow(/omit @deepseek-ai\/dsh-desktop-host/u) + }) + + it('requires the Desktop Host entry and its packaged overlay', () => { + const files = [ + 'package/lib/index.js', + 'package/config/desktop.cordis.patch.yml', + ] + expect(() => { + assertDesktopHostPackageFiles(files) + }).not.toThrow() + expect(() => { + assertDesktopHostPackageFiles(files.slice(0, 1)) + }).toThrow(/desktop\.cordis\.patch\.yml/u) + expect(() => { + assertDesktopHostPackageFiles(files.slice(1)) + }).toThrow(/lib\/index\.js/u) + }) +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts new file mode 100644 index 0000000000..f5320b47bd --- /dev/null +++ b/apps/desktop/tests/project-manager.spec.ts @@ -0,0 +1,392 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, sep } 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' + +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' +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'), '') + } +} +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, + } +} + +function release(version = '1.0.0'): DesktopRelease { + return { + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + } +} + +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', () => { + 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 + } + 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) + expect(manager.listPlugins()).toEqual([]) + expect(manager.dshVersion()).toBe('1.0.0') + expect(starts).toBe(2) + }) + + 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()) + 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(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) + }) +}) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts new file mode 100644 index 0000000000..b2f5fc614a --- /dev/null +++ b/apps/desktop/tests/seed-store.spec.ts @@ -0,0 +1,167 @@ +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/apps/desktop/tests/single-instance.spec.ts b/apps/desktop/tests/single-instance.spec.ts new file mode 100644 index 0000000000..220f0d5a30 --- /dev/null +++ b/apps/desktop/tests/single-instance.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { claimDesktopSingleInstance, type DesktopSingleInstanceApplication } from '../src/single-instance.ts' + +describe('desktop single-instance ownership', () => { + it('quits a second process without registering lifecycle work', () => { + const quit = vi.fn() + const on = vi.fn() + const application = { + requestSingleInstanceLock: () => false, + quit, + on, + } satisfies DesktopSingleInstanceApplication + + expect(claimDesktopSingleInstance(application, vi.fn())).toBe(false) + expect(quit).toHaveBeenCalledOnce() + expect(on).not.toHaveBeenCalled() + }) + + it('routes a later launch to the primary process', () => { + let secondInstance: (() => void) | undefined + const focus = vi.fn() + const application = { + requestSingleInstanceLock: () => true, + quit: vi.fn(), + on: vi.fn((_event: 'second-instance', listener: () => void) => { secondInstance = listener }), + } satisfies DesktopSingleInstanceApplication + + expect(claimDesktopSingleInstance(application, focus)).toBe(true) + secondInstance?.() + expect(focus).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts new file mode 100644 index 0000000000..272ef03a1d --- /dev/null +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppUpdater } from 'electron-updater' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { parseDesktopRelease } from '../src/release.ts' +import type { DesktopUpdateState } from '../src/ipc.ts' + +vi.mock('electron', () => ({ app: { isPackaged: false } })) +vi.mock('electron-updater', () => ({ + default: { autoUpdater: { autoDownload: true, autoInstallOnAppQuit: true } }, +})) + +const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts') + +describe('desktop release metadata', () => { + it('accepts one exact release identity for Electron and dsh', () => { + expect(parseDesktopRelease({ + schemaVersion: 1, + version: '1.2.3', + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + })).toEqual({ + schemaVersion: 1, + version: '1.2.3', + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + }) + }) + + it('rejects invalid versions and unsupported host protocols', () => { + const base = { + schemaVersion: 1, + version: '1.2.3', + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: '24.17.0', + pnpmVersion: '11.7.0', + } + expect(() => parseDesktopRelease({ ...base, version: 'latest' })).toThrow(/invalid desktop release metadata/u) + expect(() => parseDesktopRelease({ ...base, hostProtocolVersion: 999 })).toThrow(/invalid desktop release metadata/u) + }) +}) + +describe('desktop update coordinator', () => { + it('installs one Electron release and restarts after download', async () => { + const states: DesktopUpdateState[] = [] + const downloadUpdate = vi.fn(async () => []) + const quitAndInstall = vi.fn() + const beforeRestart = vi.fn(async () => {}) + const updater = { + autoDownload: true, + autoInstallOnAppQuit: true, + checkForUpdates: vi.fn(async () => ({ + isUpdateAvailable: true, + updateInfo: { version: '1.1.0' }, + })), + downloadUpdate, + quitAndInstall, + } as unknown as AppUpdater + const coordinator = new DesktopUpdateCoordinator( + (state) => { + states.push(state) + return state + }, + beforeRestart, + updater, + () => true, + ) + + await expect(coordinator.check()).resolves.toEqual({ phase: 'available', version: '1.1.0' }) + await expect(coordinator.install()).resolves.toEqual({ phase: 'ready', version: '1.1.0' }) + expect(downloadUpdate).toHaveBeenCalledOnce() + expect(beforeRestart).toHaveBeenCalledOnce() + expect(quitAndInstall).toHaveBeenCalledWith(false, true) + expect(states.map(state => state.phase)).toEqual(['checking', 'available', 'installing', 'ready']) + }) + + it('queues install behind an in-flight check instead of returning the check result', async () => { + const checked = Promise.withResolvers<{ + isUpdateAvailable: true + updateInfo: { version: string } + }>() + const downloadUpdate = vi.fn(async () => []) + const updater = { + autoDownload: true, + autoInstallOnAppQuit: true, + checkForUpdates: vi.fn(() => checked.promise), + downloadUpdate, + quitAndInstall: vi.fn(), + } as unknown as AppUpdater + const coordinator = new DesktopUpdateCoordinator(state => state, async () => {}, updater, () => true) + + const checking = coordinator.check() + const installing = coordinator.install() + expect(downloadUpdate).not.toHaveBeenCalled() + checked.resolve({ isUpdateAvailable: true, updateInfo: { version: '1.2.0' } }) + + await expect(checking).resolves.toEqual({ phase: 'available', version: '1.2.0' }) + await expect(installing).resolves.toEqual({ phase: 'ready', version: '1.2.0' }) + expect(downloadUpdate).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts new file mode 100644 index 0000000000..9005d24f6f --- /dev/null +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -0,0 +1,226 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + buildWindowsSigningEnvironment, + createRedactedWindowsSigningError, + createWindowsTokenSigner, + installWindowsNsisBootstrapSigner, + repairDanglingAuthenticodeDirectory, + scrubWindowsSigningEnvironment, +} from '../scripts/windows-sign.mjs' + +vi.mock('node:crypto', () => ({ + X509Certificate: class { + readonly ca = false + readonly keyUsage = ['1.3.6.1.5.5.7.3.3'] + + constructor(contents: Buffer) { + if (contents.toString('utf8') !== 'code-signing-certificate-fixture') { + throw new Error('invalid test certificate') + } + } + }, +})) + +const CERTIFICATE_FILE = 'C:\\release\\server.cer' +const SIGN_SCRIPT = resolve(import.meta.dirname, '../scripts/windows-sign.cmd') + +describe('Windows token signing', () => { + it('passes only the validated BAT fields to the signing command interpreter', () => { + expect(buildWindowsSigningEnvironment({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'inherited-token-secret', + DEEPSEEK_API_KEY: 'api-secret', + BUILD_PASSWORD: 'build-secret', + }, { + certificateFile: CERTIFICATE_FILE, + signTool: 'C:\\tools\\signtool.exe', + path: 'C:\\release\\DeepSeek Harness.exe', + isNest: false, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toEqual({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_WINDOWS_CER_FILE: CERTIFICATE_FILE, + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret!', + DSH_DESKTOP_WINDOWS_KEY_CONTAINER: 'te-container', + DSH_DESKTOP_WINDOWS_SIGN_TARGET: 'C:\\release\\DeepSeek Harness.exe', + DSH_DESKTOP_WINDOWS_SIGN_APPEND: '', + }) + }) + + it('requests an appended signature only for an electron-builder nested task', () => { + expect(buildWindowsSigningEnvironment({}, { + certificateFile: CERTIFICATE_FILE, + signTool: 'C:\\tools\\signtool.exe', + path: 'C:\\release\\setup.exe', + isNest: true, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + }).DSH_DESKTOP_WINDOWS_SIGN_APPEND).toBe('1') + }) + + it('keeps the verified SafeNet command in an ASCII CRLF CMD file', async () => { + const contents = await readFile(SIGN_SCRIPT) + const text = contents.toString('ascii') + expect(contents.every(byte => byte <= 0x7F)).toBe(true) + expect(text).toContain('\r\n') + expect(text.replaceAll('\r\n', '')).not.toContain('\n') + expect(text).toContain('setlocal DisableDelayedExpansion\r\n') + expect(text).toContain('set "DSH_DESKTOP_WINDOWS_CER_FILE="\r\n') + expect(text).toContain('set "DSH_DESKTOP_WINDOWS_TOKEN_PIN="\r\n') + expect(text).toContain('"%signTool%" sign /v /fd sha256 /f "%certificateFile%" /kc "[{{%tokenPin%}}]=%keyContainer%" /csp "eToken Base Cryptographic Provider" %appendSignature% /tr http://timestamp.digicert.com /td sha256 "%targetFile%"\r\n') + }) + + it('rejects incomplete signing identities and non-SHA-256 signing tasks', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-tool-')) + const certificateFile = join(directory, 'server.cer') + const signTool = join(directory, 'signtool.exe') + await writeFile(certificateFile, 'code-signing-certificate-fixture') + await writeFile(signTool, 'fixture') + expect(() => createWindowsTokenSigner({ + certificateFile: undefined, + signTool, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool: undefined, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_SIGNTOOL/u) + const signer = createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token-secret!', + keyContainer: 'te-container', + }) + try { + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token-secret!', + })).toThrow(/DSH_DESKTOP_WINDOWS_KEY_CONTAINER/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: '', + keyContainer: 'te-container', + })).toThrow(/DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => createWindowsTokenSigner({ + certificateFile, + signTool, + tokenPin: 'token]secret', + keyContainer: 'te-container', + })).toThrow(/cannot contain/u) + await expect(signer({ + path: 'C:\\release\\setup.exe', + hash: 'sha1', + isNest: false, + })).rejects.toThrow(/requires SHA-256/u) + } + finally { + await rm(directory, { recursive: true }) + } + }) + + it('removes inherited credentials and redacts SignTool process failures', () => { + expect(scrubWindowsSigningEnvironment({ + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', + DSH_DESKTOP_WINDOWS_SIGNTOOL: 'C:\\tools\\signtool.exe', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + DEEPSEEK_API_KEY: 'api-secret', + BUILD_PASSWORD: 'build-secret', + })).toEqual({ SystemRoot: 'C:\\Windows' }) + + const processError = Object.assign(new Error('failed'), { + code: 1, + cmd: 'signtool /kc [{{token-secret}}]=te-container', + stderr: 'provider rejected token-secret', + }) + const failure = createRedactedWindowsSigningError( + processError, + 'C:\\release\\setup.exe', + ['token-secret'], + ) + expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected ') + expect(failure.message).not.toContain('token-secret') + expect(failure).not.toHaveProperty('cause') + expect(failure).not.toHaveProperty('cmd') + }) + + it('signs the temporary NSIS executable before enterprise policy evaluates it', async () => { + const events: string[] = [] + let receivedEnvironment: NodeJS.ProcessEnv | undefined + class FakeWineVmManager { + async exec( + file: string, + _args: string[], + options?: { env?: NodeJS.ProcessEnv }, + ): Promise { + events.push(`exec:${file}`) + receivedEnvironment = options?.env + return 'executed' + } + } + installWindowsNsisBootstrapSigner({ + sign: async (configuration) => { + events.push(`sign:${configuration.path}:${configuration.hash}:${String(configuration.isNest)}`) + }, + wineVmManager: FakeWineVmManager, + platform: 'win32', + environment: { + SystemRoot: 'C:\\Windows', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + }, + }) + + const result = await new FakeWineVmManager().exec('C:\\release\\setup.exe', [], { + env: { + __COMPAT_LAYER: 'RunAsInvoker', + BUILD_PASSWORD: 'build-secret', + }, + }) + + expect(result).toBe('executed') + expect(events).toEqual([ + 'sign:C:\\release\\setup.exe:sha256:false', + 'exec:C:\\release\\setup.exe', + ]) + expect(receivedEnvironment).toEqual({ + SystemRoot: 'C:\\Windows', + __COMPAT_LAYER: 'RunAsInvoker', + }) + }) + + it('clears a certificate table inherited beyond the generated uninstaller', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-windows-sign-')) + const path = join(directory, 'uninstaller.exe') + const executable = Buffer.alloc(512) + const peOffset = 216 + const optionalHeaderOffset = peOffset + 24 + const certificateDirectoryOffset = optionalHeaderOffset + 96 + (4 * 8) + executable.write('MZ', 0, 'ascii') + executable.writeUInt32LE(peOffset, 60) + executable.write('PE\0\0', peOffset, 'ascii') + executable.writeUInt16LE(0x10B, optionalHeaderOffset) + executable.writeUInt32LE(600, certificateDirectoryOffset) + executable.writeUInt32LE(100, certificateDirectoryOffset + 4) + await writeFile(path, executable) + try { + await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(true) + const repaired = await readFile(path) + expect(repaired.readUInt32LE(certificateDirectoryOffset)).toBe(0) + expect(repaired.readUInt32LE(certificateDirectoryOffset + 4)).toBe(0) + await expect(repairDanglingAuthenticodeDirectory(path)).resolves.toBe(false) + } + finally { + await rm(directory, { recursive: true }) + } + }) +}) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000000..963a3f71c5 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../packages/util/home-paths" } + ] +} diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts new file mode 100644 index 0000000000..6f7fa58c8d --- /dev/null +++ b/apps/desktop/tsdown.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig([ + { + entry: ['lib/types/main.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { neverBundle: ['electron'] }, + }, + { + // 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', + }, + outDir: 'lib', + format: ['cjs'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + deps: { neverBundle: ['electron'] }, + }, +]) diff --git a/benchmarks/agent-continuation/agent-continuation.worker.ts b/benchmarks/agent-continuation/agent-continuation.worker.ts index 7e676e9971..91faa5f347 100644 --- a/benchmarks/agent-continuation/agent-continuation.worker.ts +++ b/benchmarks/agent-continuation/agent-continuation.worker.ts @@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts' @@ -82,7 +81,6 @@ async function measure(root: string, scenario: string): Promise { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) const agentScenario = scenario === 'agent-resume' if (agentScenario) await mountAgentLoopTestDependencies(ctx) - else await ctx.plugin(SessionStore) + else { + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SessionStore) + } await installProjectionSet(ctx, agentScenario) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' }) let history: SessionHistoryController | undefined diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 12a7a1d617..fd58c7ae00 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: fcb9c1e59b60dc059ab66b64ac26acd3c9157c96 -architecture.zh.md: d7a0a3833ebf9397837967065249d7fe1650d707 +architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02 +architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7 diff --git a/docs/architecture.md b/docs/architecture.md index fcb9c1e59b..a77dfd06c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,12 @@ Vendored CLIs, build-only and test-only executables, direct in-process plugin mo The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. +## 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. + +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`. + ## Core packages Here are some core packages that contribute to the Cordis tree. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index d7a0a3833e..83fa958609 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -46,6 +46,12 @@ Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 +## 桌面应用 + +[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 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 + ## 核心包 以下是向 Cordis 树贡献内容的部分核心包。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7aafa5de3d..933ebbaa13 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: 90ec60c30c5ef2ef5fcf75e18ab3d2b51f9cac60 -config-catalog.zh.md: e5c3134561393afe3267808ee04a4cf46399a0f9 +config-catalog.md: 32644b4782cc424835f9e303fff5f30ce95cd14f +config-catalog.zh.md: 24dc4a3cb7acc1b793d25e5ac3cafc1b5e86e14a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90ec60c30c..32644b4782 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -315,7 +315,7 @@ Source: [`packages/shell/bash-sandbox/src/index.ts:36`](../packages/shell/bash-s ## `@deepseek-ai/dsh-client-connection` -Requires: `webServer` · `credentials` +Requires: `credentials` ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3397,7 +3397,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) -- `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-modules` — requires `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e5c3134561..24dc4a3cb7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -317,7 +317,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` -需要: `webServer` · `credentials` +需要:`credentials` ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3399,7 +3399,7 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) -- `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-modules` — 需要 `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f0995f4194..df5d25aa81 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: c352fe57795ee3c0c8f8a4adef0b06ff109dec76 -event-producer-consumer.zh.md: 5a091d09ca9ba8ea9ee3274096b1cca181768cfe +event-producer-consumer.md: edf5ea33526afb46e941314852dea653105fb748 +event-producer-consumer.zh.md: f7d96a94eef611817e9a9c0c2dcc93d62ea3a178 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c352fe5779..edf5ea3352 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,19 +9,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 5a091d09ca..f7d96a94ee 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,19 +11,19 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:315`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:213`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:345`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:242`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:250`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:276`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:289`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:305`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:369`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:296`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:304`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:330`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:343`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:359`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:387`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -56,10 +56,10 @@ | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 79ef8b4b09..c59e8ddf45 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: 0951780b0bf53b35f75ef13c50a3875138d756cc -module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4 +module-graph.md: 2c31c50117cc6caabccdcb8fdf6afc9a160fe4dd +module-graph.zh.md: d41df4241163ca9b17e47c62051a891e45e131e0 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0951780b0b..2c31c50117 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -457,6 +457,7 @@ flowchart TD pkg_agent --> pkg_session_projection pkg_agent --> pkg_system_prompt pkg_agent --> pkg_typert_protocol + pkg_agent --> pkg_util_values pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -868,11 +869,6 @@ flowchart TD pkg_tool_terminal --> pkg_system_prompt pkg_tool_terminal --> pkg_terminal pkg_tool_terminal --> pkg_tools - pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_llm - pkg_agent_loop_testkit --> pkg_session - pkg_agent_loop_testkit --> pkg_system_prompt - pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm @@ -946,6 +942,13 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval + pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_agent_loop + pkg_agent_loop_testkit --> pkg_llm + pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_session_projection + pkg_agent_loop_testkit --> pkg_system_prompt + pkg_agent_loop_testkit --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1290,7 +1293,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1377,7 +1380,6 @@ flowchart TD | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | @@ -1390,6 +1392,7 @@ flowchart TD | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2a073370bd..d41df42411 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -459,6 +459,7 @@ flowchart TD pkg_agent --> pkg_session_projection pkg_agent --> pkg_system_prompt pkg_agent --> pkg_typert_protocol + pkg_agent --> pkg_util_values pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -870,11 +871,6 @@ flowchart TD pkg_tool_terminal --> pkg_system_prompt pkg_tool_terminal --> pkg_terminal pkg_tool_terminal --> pkg_tools - pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_llm - pkg_agent_loop_testkit --> pkg_session - pkg_agent_loop_testkit --> pkg_system_prompt - pkg_agent_loop_testkit --> pkg_tools pkg_llm_replay --> pkg_compaction pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm @@ -948,6 +944,13 @@ flowchart TD pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval + pkg_agent_loop_testkit --> pkg_agent + pkg_agent_loop_testkit --> pkg_agent_loop + pkg_agent_loop_testkit --> pkg_llm + pkg_agent_loop_testkit --> pkg_session + pkg_agent_loop_testkit --> pkg_session_projection + pkg_agent_loop_testkit --> pkg_system_prompt + pkg_agent_loop_testkit --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_presets @@ -1292,7 +1295,7 @@ flowchart TD | [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) | -| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | +| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1379,7 +1382,6 @@ flowchart TD | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | @@ -1392,6 +1394,7 @@ flowchart TD | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 2671f9fa0c..6775871bcd 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: c47cd8a09d4c7a5179bb36d9c62611564b95a5cc -persistence-catalog.zh.md: c67d315c91565cee5855ee5b0667672151b83323 +persistence-catalog.md: 8d6f66dc949b6d41530eb387534689dc66f1e99f +persistence-catalog.zh.md: 06d07ceab984ad20ee6637227d54fafbbb58bdd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index c47cd8a09d..8d6f66dc94 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -101,8 +101,8 @@ Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src ```ts persistence-catalog /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget @@ -113,7 +113,7 @@ Sources: [`packages/core/session/src/types.ts:385`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts) ### `agent-preset/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c67d315c91..06d07ceab9 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -103,8 +103,8 @@ export type SessionEvent = { ```ts persistence-catalog /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget @@ -115,7 +115,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:87`](../packages/core/agent/src/types.ts) ### `agent-preset/*` diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index db0e53ba0e..33e35b5377 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md -client-modules.md: f58cb6592a009292ffc4f87a207fe4e103fd2365 -client-modules.zh.md: 18c72dfba6f851776100a9b6200e7222d580db7c +client-modules.md: 12925fa8ddb7c7193af76cf1bed5894f5d7d75e0 +client-modules.zh.md: 513391af8369a5ba4d9a439a14f7fb5eb1f45d27 diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index f58cb6592a..12925fa8dd 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -130,6 +130,15 @@ graph(): WebBootGraph */ clientPath(id: string): string | undefined +/** + * Serve an advertised revisioned bundle or source map without a Web server. + * Unknown URLs return 404, unsupported methods return 405, and `HEAD` + * returns the same immutable headers without a body. + * @param request - shell-carrier request for a `/plugins` resource. + * @returns the exact response also exposed by the optional Web route. + */ +fetchBundle(request: Request): Response + /** * Filesystem baseline captured before an entry's current bytes were read. * HMR compares it with the live files when installing a watch, so a write diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index 18c72dfba6..513391af83 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -130,6 +130,15 @@ graph(): WebBootGraph */ clientPath(id: string): string | undefined +/** + * Serve an advertised revisioned bundle or source map without a Web server. + * Unknown URLs return 404, unsupported methods return 405, and `HEAD` + * returns the same immutable headers without a body. + * @param request - shell-carrier request for a `/plugins` resource. + * @returns the exact response also exposed by the optional Web route. + */ +fetchBundle(request: Request): Response + /** * Filesystem baseline captured before an entry's current bytes were read. * HMR compares it with the live files when installing a watch, so a write diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 811c2e5d21..25c3042d2f 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: 29e332d068857254e3cd27892adda36a9229a0d7 -core.zh.md: c148b8f6a48d7f01b12f59b5fc14c655a1fb8daa +core.md: 4af3a22478f324655dea1b132f0c8a8528f4b883 +core.zh.md: 7359e5aaa75e9f750d44f1386acab388665e1e1e diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 29e332d068..4af3a22478 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -65,7 +65,7 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session - /** The agent-owned projection of durable pending work. */ + /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus @@ -210,12 +210,69 @@ Dispatch requires `provider` and `model` after `agent/request`. An explicit `rea The inbox is the delivery vocabulary — two ordered pending-message lists the agent owns as a durable projection: +```ts type-equiv +/** Agent-owned access to pending work; concrete storage belongs to the driver. */ +interface Inbox { + /** Prompts awaiting individual turns. */ + readonly nextTurn: readonly UserMessage[] + /** Input awaiting the next step boundary. */ + readonly nextStep: readonly UserMessage[] + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void + + /** + * Append one message to a pending list. + * @param target - pending list to extend. + * @param message - message to append. + */ + append(target: InboxTarget, message: UserMessage): void + + /** + * Prepend one message to a pending list. + * @param target - pending list to extend. + * @param message - message to prepend. + */ + prepend(target: InboxTarget, message: UserMessage): void + + /** + * Replace one pending message in place. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean + + /** + * Remove one pending message. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean + + /** + * Apply standard splice semantics and durably record the normalized result. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] +} +``` + ```ts type-equiv /** One of the two ordered pending-message lists owned by an agent. */ type InboxTarget = 'next-turn' | 'next-step' ``` -Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without emitting discarded notifications, and the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. The structural `Inbox` methods record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. At a step boundary, dsh-agent-loop's package-internal `ReactLoopInbox` removes the proposed batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without discarded notifications, then emits per-message claimed notifications. Loop-only pending detection and claiming are not part of `Agent.inbox`. Each `ReactLoopInbox` constructor contributes the standard `inbox` projection from its agent scope; the registry shares that definition across agents by reference count, and its cell is the sole live state while the same fold serves cold consumers. The fold rejects unsafe or out-of-range splice coordinates and duplicate identities across both lists, identifying malformed durable history by event seq. Consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index c148b8f6a4..7359e5aaa7 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -69,7 +69,7 @@ interface Agent { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session - /** The agent-owned projection of durable pending work. */ + /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus @@ -214,12 +214,69 @@ interface AgentOptions { inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待处理消息列表: +```ts type-equiv +/** Agent-owned access to pending work; concrete storage belongs to the driver. */ +interface Inbox { + /** Prompts awaiting individual turns. */ + readonly nextTurn: readonly UserMessage[] + /** Input awaiting the next step boundary. */ + readonly nextStep: readonly UserMessage[] + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void + + /** + * Append one message to a pending list. + * @param target - pending list to extend. + * @param message - message to append. + */ + append(target: InboxTarget, message: UserMessage): void + + /** + * Prepend one message to a pending list. + * @param target - pending list to extend. + * @param message - message to prepend. + */ + prepend(target: InboxTarget, message: UserMessage): void + + /** + * Replace one pending message in place. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean + + /** + * Remove one pending message. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean + + /** + * Apply standard splice semantics and durably record the normalized result. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] +} +``` + ```ts type-equiv /** One of the two ordered pending-message lists owned by an agent. */ type InboxTarget = 'next-turn' | 'next-step' ``` -每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。结构化 `Inbox` 方法会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。在步骤边界,dsh-agent-loop 包内部的 `ReactLoopInbox` 会通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知,随后逐条发出 claimed 通知。仅供循环使用的待处理检测与领取操作不属于 `Agent.inbox`。每个 `ReactLoopInbox` 构造函数都从其 agent 作用域贡献标准 `inbox` 投影;注册表通过引用计数在多个 agent 之间共享该定义,其 cell 是唯一 live 状态,同一份折叠也服务于冷消费方。该 fold 会拒绝不安全或越界的 splice 坐标,以及跨两份列表重复的标识,并通过事件 seq 指出格式错误的持久历史。跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: diff --git a/package.json b/package.json index ace76e228d..422cce1ce6 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,25 @@ "build": "tsx scripts/build.ts", "build:bench": "npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", - "build:lib": "npm run build:lib:host && npm run build:lib:client", + "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", + "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", + "dev:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run dev", + "start:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run start", + "prepare:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run prepare:package", + "package:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run package", + "package:desktop:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:dir", + "package:desktop:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:arm64", + "package:desktop:mac:arm64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:arm64:dir", + "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: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", + "upload:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index aa37bf1bc8..51d89dab25 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -23,7 +23,6 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as AcpPlugin from '../src/index.ts' @@ -232,10 +231,6 @@ export async function makeBridgeHarness(options: { const ownsPersistenceRoot = options.persistenceRoot === undefined const persistenceRoot = options.persistenceRoot ?? await mkdtemp(join(tmpdir(), 'dsh-acp-test-')) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: options.persona ?? '' } }) - // The agent loop and the composed approval/permission services declare - // sessionProjections a required injection: mount the registry (and with it - // the loop's turnBoundary unit) before the loop activates. - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) await ctx.plugin(TokenMeter) if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 6ae203a68b..981e5f12ca 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: a2639ebd5fb648ed193d7c3575a649891ebf76b9 -README.zh.md: 7bed20d3e47855b045bb838b354328f1769273e2 +README.md: 6b56c4976b5ca66fbdbdb274bdf5bf8f38d52b1d +README.zh.md: a9c53d37371e3280c5bc0ae21a93a511036a810d diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index a2639ebd5f..6b56c4976b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,7 +27,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. For each inbox change, the Host publishes the projection frame first and derives the queue replacement from that same validated post-fold value, so listener registration order cannot produce a stale queue frame.Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 7bed20d3e4..a9c53d3737 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器,按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 inbox 变更时,Host 会先发布 projection frame,再从同一份已校验的折叠后值派生 queue replacement,因此监听器注册顺序不会产生陈旧的 queue frame。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index ffc8f45118..65b757e0e1 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -118,6 +118,8 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 1a03011644..9bdd5a05bf 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,11 +1,11 @@ /** Live Session queue, jobs, and projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, InboxState } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { - Session, SessionEvent, SessionEventMap, SessionId, UserMessage, + Session, SessionId, UserMessage, } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { @@ -23,7 +23,6 @@ export class SessionControlController { /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ constructor(private readonly ctx: Context) { - ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) ctx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ type: 'projection', @@ -32,6 +31,14 @@ export class SessionControlController { value: value as JsonValue, seq, }) + if (key !== 'inbox') return + const agent = this.ctx.agents.get(session.id) + if (agent?.session !== session) return + this.broadcast({ + type: 'queue', + sessionId: session.id, + items: queueItemsFromInbox(value as InboxState), + }) }) ctx.inject(['jobs'], (jobsCtx) => { jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) @@ -95,17 +102,6 @@ export class SessionControlController { return blocks } - private onSessionEvent(session: Session, event: SessionEvent): void { - if (event.type !== 'agent/inbox/spliced') return - const agent = this.ctx.agents.get(session.id) - if (agent?.session !== session) return - this.broadcast({ - type: 'queue', - sessionId: session.id, - items: queueItems(agent, event.data), - }) - } - private onJobsChanged(owner: Agent | undefined): void { if (owner !== undefined) { this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) @@ -171,24 +167,22 @@ class ControlQueue { } } -function queueItems( - agent: Agent, - splice?: SessionEventMap['agent/inbox/spliced'], -): SessionQueuedItem[] { - const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { - const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep - return splice?.target === target - ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) - : messages - } +function queueItems(agent: Agent): SessionQueuedItem[] { + return queueItemsFromInbox({ + 'next-turn': agent.inbox.nextTurn, + 'next-step': agent.inbox.nextStep, + }) +} + +function queueItemsFromInbox(inbox: InboxState): SessionQueuedItem[] { return [ - ...project('next-turn').map(message => ({ + ...inbox['next-turn'].map(message => ({ id: message.id, placement: 'queued' as const, ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), - ...project('next-step').map(message => ({ + ...inbox['next-step'].map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, ...promptRpcId(message), diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 30665e979c..e96a3b128b 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, Inbox, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' @@ -13,6 +13,7 @@ import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/ import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' async function commandHarness( @@ -68,7 +69,7 @@ async function commandHarness( provider: 1, } as never) } - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = createInboxStub() const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) }) const cancel = vi.fn() const agent = { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 4e7565c7ff..a6073c5f2a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { @@ -38,7 +39,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = createInboxStub() const followup = vi.fn() const agent = { id: session.id, diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index cf6378f5e8..4c88ba21df 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { JobOutcome } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -9,6 +9,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { describe, expect, it } from 'vitest' import { SessionControlController } from '../src/control.ts' import type { SessionControlFrame } from '../src/types.ts' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' type BaselineFrame = Extract type JobFrame = Extract @@ -36,20 +37,28 @@ async function harness(withJobs: boolean): Promise<{ }> { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) if (withJobs) { await ctx.plugin(LocalJobRegistry) ctx.jobs.attachController('session-controller-test') } const session = ctx.sessions.create() - const agent = { + const agent: Agent = { id: session.id, + options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx, - } as Agent + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } ctx.agents.register(agent) const control = new SessionControlController(ctx) await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 0a1e5420a7..b3f1d9b19d 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -1,11 +1,20 @@ import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it } from 'vitest' import { SessionControlController } from '../src/control.ts' +import type { SessionControlFrame } from '../src/types.ts' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' + +const ownedContexts = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() +}) async function harness(): Promise<{ ctx: Context @@ -14,14 +23,11 @@ async function harness(): Promise<{ inbox: Inbox }> { const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) - const session = ctx.sessions.create(SessionId('queue-session')) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent - ctx.agents.register(agent) - return { ctx, control: new SessionControlController(ctx), agent, inbox } + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const agent = await loop.create(SessionId('queue-session')) + return { ctx, control: new SessionControlController(ctx), agent, inbox: agent.inbox } } function message(text: string, source: 'user' | 'plugin' = 'user') { @@ -32,6 +38,17 @@ function message(text: string, source: 'user' | 'plugin' = 'user') { } describe('Session control queue projection', () => { + /** Consume frames until the next queue replacement (inbox projection frames interleave). */ + async function nextQueueFrame( + iterator: AsyncIterator, + ): Promise> { + for (;;) { + const next = await iterator.next() + if (next.done) throw new Error('stream ended before a queue frame') + if (next.value.type === 'queue') return next.value + } + } + it('projects both pending lists in baselines and live replacement frames', async () => { const { control, inbox } = await harness() const queued = message('queued') @@ -59,13 +76,41 @@ describe('Session control queue projection', () => { const replacement = message('replacement') inbox.append('next-turn', replacement) - const replaced = await iterator.next() - if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement') - expect(replaced.value.items.map(item => item.id)).toContain(replacement.id) + const replaced = await nextQueueFrame(iterator) + expect(replaced.items.map(item => item.id)).toContain(replacement.id) inbox.remove(steering.id) - const removed = await iterator.next() - if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement') - expect(removed.value.items.map(item => item.id)).not.toContain(steering.id) + const removed = await nextQueueFrame(iterator) + expect(removed.items.map(item => item.id)).not.toContain(steering.id) + + abort.abort() + await iterator.next() + }) + + it('derives queue replacements from the completed projection regardless of registration order', async () => { + const ctx = new Context() + ownedContexts.add(ctx) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const control = new SessionControlController(ctx) + const agent = await loop.create(SessionId('late-projection-queue')) + const { inbox } = agent + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const pending = message('late projection') + + inbox.append('next-turn', pending) + + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: 'projection', + key: 'inbox', + value: { 'next-turn': [{ id: pending.id }], 'next-step': [] }, + }, + }) + await expect(nextQueueFrame(iterator)).resolves.toMatchObject({ + items: [{ id: pending.id, placement: 'queued' }], + }) abort.abort() await iterator.next() @@ -133,14 +178,19 @@ describe('Session control queue projection', () => { const { ctx, control, inbox } = await harness() const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]() await iterator.next() - inbox.append('next-turn', message('first')) - inbox.append('next-turn', message('second')) + const first = message('first') + const second = message('second') + inbox.append('next-turn', first) + inbox.append('next-turn', second) - const first = await iterator.next() - expect(first).toMatchObject({ done: false, value: { type: 'queue' } }) + const queues: Extract[] = [] + ownedContexts.delete(ctx) await ctx.fiber.dispose() - const second = await iterator.next() - expect(second).toMatchObject({ done: false, value: { type: 'queue' } }) - await expect(iterator.next()).resolves.toMatchObject({ done: true }) + for (;;) { + const next = await iterator.next() + if (next.done) break + if (next.value.type === 'queue') queues.push(next.value) + } + expect(queues.map(queue => queue.items.map(item => item.id))).toEqual([[first.id], [first.id, second.id]]) }) }) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index deb58794ca..088fefad1c 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -8,14 +8,15 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek- import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { Agent, Inbox } from '@deepseek-ai/dsh-agent' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, @@ -42,8 +43,8 @@ function promptRequest( } } -function inboxFor(session: Session): Inbox { - return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) +function inboxFor(): Inbox { + return createInboxStub() } function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { @@ -545,7 +546,7 @@ describe('subagent ownership fence', () => { }) const followup = vi.fn() const agent = { - id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -566,7 +567,7 @@ describe('subagent ownership fence', () => { const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() const agent = { - id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { @@ -687,7 +688,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register({ id: session.id, session, - inbox: inboxFor(session), + inbox: inboxFor(), status: 'idle', ctx, followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index 1456e87abc..90ffb10456 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -7,19 +7,18 @@ * pushed through the control stream. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' -import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' @@ -27,8 +26,19 @@ import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import * as StorageJson from '@deepseek-ai/dsh-storage-json' import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts' +const ownedContexts = new Set() +afterEach(async () => { + await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) + ownedContexts.clear() +}) +let nextHarnessSession = 1 + declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { 'test/last-user': LastUserState @@ -108,15 +118,35 @@ const privatePromptUnit = () => ({ stateVersion: 1, }) satisfies ProjectionDefinition<'test/private-prompt', string | null> -async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> { +async function harness(withRegistry: boolean): Promise<{ + ctx: Context + session: Session + readonly claim: (target: 'next-turn' | 'next-step') => UserMessage[] +}> { const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - if (withRegistry) await ctx.plugin(SessionProjectionRegistry) - const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) - // The gateway reads both the session and durable inbox baseline. - ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent) - return { ctx, session } + ownedContexts.add(ctx) + if (!withRegistry) { + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + return { + ctx, + session, + claim: () => { throw new Error('inbox is unavailable without the projection registry') }, + } + } + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const agent = await loop.create( + SessionId(`session-projections-${String(nextHarnessSession++)}`), + {}, + { cwd: '/workspace' }, + ) + return { + ctx, + session: agent.session, + claim: target => loop.claim(agent, target, 1), + } } /** Append `count` user messages so the log has paginable message boundaries. */ @@ -205,6 +235,74 @@ describe('session.history projections block', () => { expect(last?.event.seq).toBe(projections.asOfSeq) }) + it('reconstructs a cold persisted queue without publishing or resuming an Agent', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('cold-persisted-queue') + const meta: SessionHeader = { version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 1, cwd: '/tmp', isSeeded: false } + const message = createUserMessage({ + content: [{ type: 'text', text: 'survive process restart' }], + source: { kind: 'user' }, + }) + const events: SessionEvent[] = [{ + type: 'agent/inbox/spliced', + seq: SessionSeq(0), + time: 2, + data: { target: 'next-turn', start: 0, inserted: [message] }, + }] + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }), + }) as never) + const snapshot = await opening(remote(ctx), coldId) + + expect(snapshot.projections.values.inbox).toEqual({ + 'next-turn': [message], + 'next-step': [], + }) + expect(ctx.agents.get(coldId)).toBeUndefined() + expect(ctx.sessions.get(coldId)).toBeUndefined() + }) + + it('removes claimed steering from the pending Inbox projection immediately', async () => { + const { ctx, session, claim } = await harness(true) + const proxy = remote(ctx) + const message = createUserMessage({ + content: [{ type: 'text', text: 'apply this now' }], + source: { kind: 'user' }, + }) + const agent = ctx.agents.get(session.id) + if (agent === undefined) throw new Error('missing Agent') + agent.inbox.append('next-step', message) + claim('next-step') + + const during = await opening(proxy, session.id) + expect(during.projections.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + + session.append('user/message', message, { surfaceOp: 'append' }) + const settled = await opening(proxy, session.id) + expect(settled.projections.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + + const rejected = createUserMessage({ + content: [{ type: 'text', text: 'reject this pre-step' }], + source: { kind: 'user' }, + }) + session.append('turn/start', { turn: 1 }) + agent.inbox.append('next-step', rejected) + claim('next-step') + session.append('turn/end', { turn: 1, reason: { kind: 'blocked' } }) + const closed = await opening(proxy, session.id) + expect(closed.projections.values.inbox).toEqual({ + 'next-turn': [], + 'next-step': [], + }) + }) + it('returns a complete current replacement cut on each follow generation', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 7a5663cdce..5fd15cc279 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: e6afceaebf7fc94f5a6d708639de128cb9005d72 -README.zh.md: a98be1fd5d0624f971af5d0ce5a81fdc0cafab54 +README.md: 086424009c9f7157f687a00cbb50cdc80a210e11 +README.zh.md: 4d0e45dd6be961d22d638134541c80501edec505 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index e6afceaebf..086424009c 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -47,7 +47,7 @@ With that entry point, success looks like a running app with every plugin active Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data. -A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly. +A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup. Your machine-local preferences also live in the Harness home: diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a98be1fd5d..4d0e45dd6b 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO Profile 与 bundle 的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。 -profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。 +profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。 你的机器本地偏好同样位于 harness home 中: diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 634aaee577..ef891cef0d 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -34,6 +34,7 @@ export { healProfilesModuleFallback, initProfile, loadProfile, + loadProfileDirectory, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, PROFILES_DIR, diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 3c97bab40c..64f65878ec 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -761,6 +761,48 @@ export function resolveBundleDir( ) } +/** + * Load an already initialized profile directory without resolving it through + * the shared Harness home. This is used by application-owned profiles whose + * package project and lifecycle belong to that application. + * @param binName - the diagnostic prefix on thrown errors. + * @param dir - absolute profile package directory. + * @param installAnchor - absolute path of the owning dsh app's package.json. + * @param options - `userLayer: false` skips reading `cordis.patch.yml`. + * @returns the resolved bundle layers and optional user patch layer. + */ +export function loadProfileDirectory( + binName: string, + dir: string, + installAnchor: string, + options: { userLayer?: boolean } = {}, +): Profile { + const manifest = readProfileManifest(binName, dir) + const bundles = manifest.dsh?.profile?.bundles ?? [] + const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload + if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') { + throw new Error( + `${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`, + ) + } + const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD + const layers = bundles.map((packageName): ProfileLayer => { + const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) + const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest + const declared = bundleManifest.dsh?.bundle?.patch + if (declared === undefined) { + throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) + } + const patchPath = join(packageDir, declared) + return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + }) + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + const patches = options.userLayer !== false && existsSync(patchPath) + ? loadOverlayPatches(binName, patchPath) + : [] + return { name: basename(dir), dir, layers, patchPath, patches, patchReload } +} + /** * Load a profile: resolve every `dsh.profile.bundles` entry to its patch * layer and parse the profile's own patch file. A listed bundle without a @@ -789,31 +831,8 @@ export function loadProfile( } initProfile(dir, template.bundles, template.patchReload) } - const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)) - // A hand-written profile manifest may omit the dsh section entirely. - const bundles = manifest.dsh?.profile?.bundles ?? [] - const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload - if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') { - throw new Error( - `${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`, - ) - } - const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD - const layers = bundles.map((packageName): ProfileLayer => { - const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) - const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest - const declared = bundleManifest.dsh?.bundle?.patch - if (declared === undefined) { - throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) - } - const patchPath = join(packageDir, declared) - return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } - }) - const patchPath = join(dir, PROFILE_PATCH_FILENAME) - const patches = options.userLayer !== false && existsSync(patchPath) - ? loadOverlayPatches(binName, patchPath) - : [] - return { name, dir, layers, patchPath, patches, patchReload } + normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)) + return loadProfileDirectory(binName, dir, installAnchor, options) } /** diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 10d1d41b8a..3fa0cf97b4 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,6 +17,7 @@ import { healProfilesModuleFallback, initProfile, loadProfile, + loadProfileDirectory, PROFILE_PATCH_FILENAME, PROFILE_TEMPLATES, readProfileManifest, @@ -163,6 +164,16 @@ describe('resolveBundleDir', () => { }) describe('loadProfile', () => { + it('loads an explicitly owned profile directory outside CLI discovery', () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const dir = join(tmp(), 'managed', 'desktop') + initProfile(dir, ['bundle-a']) + const profile = loadProfileDirectory('managed app', dir, anchor) + expect(profile.dir).toBe(dir) + expect(profile.name).toBe('desktop') + expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a']) + }) + it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => { const anchor = stageInstallation({ 'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index b6aa2d08f1..31a817873a 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -59,6 +59,8 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^" } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index b380537720..e590b69b83 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model' import { LlmAttemptId, createAssistantMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import { apply, Config, internals } from '../src/index.ts' const originalInternals = { ...internals } @@ -82,6 +84,7 @@ async function bench(script: Script): Promise<{ let err = '' const order: string[] = [] await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' }) ctx.agents.setFactory({ @@ -89,16 +92,15 @@ async function bench(script: Script): Promise<{ const session = ctx.sessions.create(options.sessionId, { ...options.meta === undefined ? {} : { meta: options.meta }, }) + const inbox = createInboxStub() let idle = Promise.resolve() - const agent = {} as Agent - const agentCtx = ownerCtx.extend({ agent }) - Object.assign(agent, { + const agent: Agent = { id: session.id, options: options.agentOptions ?? {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox, status: 'idle', - ctx: agentCtx, + ctx: ownerCtx, cancel: () => {}, runMaintenance: () => Promise.reject(new Error('not used')), send: () => {}, @@ -109,7 +111,11 @@ async function bench(script: Script): Promise<{ steer: () => {}, inject: () => {}, whenIdle: () => idle, - } satisfies Partial) + } + const agentCtx = ownerCtx.extend({ agent }) + Object.assign(agent, { + ctx: agentCtx, + }) await options.setup?.(agentCtx) script.before?.(session) ctx.agents.register(agent) @@ -158,6 +164,25 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('ignores durable inbox events before the first owned turn', async () => { + const test = await bench({ + afterPrompt(session, message) { + session.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [message], + }) + appendTurn(session, 1, message, 'answer after inbox activity', true) + }, + }) + expect(await test.run()).toMatchObject({ + code: 0, + out: 'answer after inbox activity\n', + err: '', + }) + await test.ctx.fiber.dispose() + }) + it('waits for asynchronously appended events instead of racing Agent idleness', async () => { const test = await bench({ afterPrompt: async (session, message) => { @@ -336,16 +361,20 @@ describe('headless runner', () => { }) it('fails when an event below the captured Session length cannot be read', async () => { + let capturedLength = 0 const test = await bench({ afterPrompt(session, message) { appendTurn(session, 1, message, 'unreachable', true) + capturedLength = session.seq Object.defineProperty(session, 'eventAt', { value: () => undefined }) }, }) - expect(await test.run()).toMatchObject({ + const result = await test.run() + expect(capturedLength).toBeGreaterThan(0) + expect(result).toMatchObject({ code: 1, out: '', - err: 'dsh: headless summary cannot read seq 0 below captured length 7\n', + err: `dsh: headless summary cannot read seq 0 below captured length ${String(capturedLength)}\n`, }) await test.ctx.fiber.dispose() }) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index b5b4eab8db..27b847754e 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 4273c523039ddbc7b0644250800425c555c4c067 -README.zh.md: b416659b38a9f63d25842f7926a19eb78842e505 +README.md: 865c894403d2177e15085d0616289cf8950b555d +README.zh.md: 3d299a4be7b3490298554982eed6d99fbba9718c diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 4273c52303..865c894403 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; shell-owned compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half always provides the carrier-neutral RPC and exact `GET`/`HEAD`/`POST` route registries. When a Web carrier is present it also owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks; a shell-owned carrier dispatches the shared Fetch handler directly. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index b416659b38..3d299a4be7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。由 shell 持有的组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 始终提供与载体无关的 RPC 注册表和精确 `GET`/`HEAD`/`POST` 路由注册表。存在 Web 载体时,它还持有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;由 shell 持有的载体则直接分派共享 Fetch handler。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 469e2df5ab..98a4354760 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -66,7 +66,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } /** Services required before providing Connection. */ -export const inject = ['webServer', 'credentials'] +export const inject = ['credentials'] /** Browser authentication, request limits, and connection recovery configuration. */ export interface ConnectionConfig { @@ -95,9 +95,9 @@ export const Config: z = z.object({ }) /** - * Mounts the API gateway under the browser transport prefix. Every request on - * the prefix passes the Host/Origin browser-trust fence and persistent browser - * authentication before dispatch. + * Provides carrier-neutral RPC and Fetch registries. When `webServer` is + * present, the plugin also mounts the `/api` browser transport with Host/Origin + * checks and persistent browser authentication. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). */ @@ -116,24 +116,27 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise { - table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery }) + ctx.inject(['webServer'], (webCtx) => { + assertImageBodyCapacity(webCtx, maxRequestBodyBytes) + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: recovery }) + }) + const fetchHandler = connection.createSharedFetchHandler(API_PATH) + const route: WebRoute = { + kind: 'prefix', + path: API_PATH, + handler: async (req, res) => { + const rejection = connection.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + return + } + await bridge(req, res, fetchHandler, maxRequestBodyBytes) + }, + } + webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') }) - const fetchHandler = connection.createSharedFetchHandler(API_PATH) - const route: WebRoute = { - kind: 'prefix', - path: API_PATH, - handler: async (req, res) => { - const rejection = connection.requestRejection(req) - if (rejection !== undefined) { - res.writeHead(rejection) - res.end(rejection === 401 ? 'unauthorized' : 'forbidden') - return - } - await bridge(req, res, fetchHandler, maxRequestBodyBytes) - }, - } - ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route') ctx.inject(['attachments'], (attachmentCtx) => { assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes) }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b51f75a420..5c681f3c00 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -118,6 +118,15 @@ function browserCookie(connection: HostConnectionHandle, authority: string): str } describe('connection node half', () => { + it('provides the carrier-neutral service without a Web server', async () => { + const ctx = new Context() + provideBrowserCredentials(ctx) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.get('connection')).toBeInstanceOf(Object) + await fiber.dispose() + }) + it('injects validated browser recovery timing and withdraws it on disposal', async () => { const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } }) try { diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 16df1c0dca..cba7fe01a0 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: 7bfa39d183bb29f3f9481bb39280c5521786031a -README.zh.md: 64dc5644f535722b464c867616b18ff557e921be +README.md: 246293de32483adba0ee93d2d9bdbe8b8dbcd5fa +README.zh.md: 759eb5b94e63c7108013ed9b223393ba405adc33 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index 7bfa39d183..246293de32 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-modules` turns a plugin package's `dsh.client` declaration into a loadable browser bundle: the host half scans enabled Loader entries, composes the boot graph, and serves each bundle over `/plugins`, and the browser half loads those bundles lazily on demand. Plugin bundles execute lazily — running a bundle only registers a factory, and module side effects run at materialization — so nothing runs until a plugin is first used. Everything here is browser-kernel machinery; the model never sees it. +`dsh-client-modules` turns a plugin package's `dsh.client` declaration into a loadable browser bundle: the host half scans enabled Loader entries and composes the boot graph, an available Web carrier serves each bundle over `/plugins`, and a shell-owned carrier dispatches the same exact bundle responses through `fetchBundle()`. The browser half loads those bundles lazily on demand. Plugin bundles execute lazily — running a bundle only registers a factory, and module side effects run at materialization — so nothing runs until a plugin is first used. Everything here is browser-kernel machinery; the model never sees it. ## Table of Contents @@ -71,13 +71,13 @@ The node half snapshots each client bundle and available source map before publi ### Boot manifest injection -The host taps the index render and injects, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. +The host contributes structured index rows that inject, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, combo routes, index tap | +| [`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 | diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index 64dc5644f5..759eb5b94e 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-modules` 把插件包的 `dsh.client` 声明变成可加载的浏览器 bundle:宿主半侧扫描已启用的 Loader 条目、组合启动图,并通过 `/plugins` 提供每个 bundle;浏览器半侧按需惰性加载这些 bundle。插件 bundle 惰性执行——运行 bundle 只注册 factory,模块副作用在物化时运行——因此插件首次被使用之前什么都不会运行。这里的一切都是浏览器内核机制;模型永远看不到它。 +`dsh-client-modules` 把插件包的 `dsh.client` 声明变成可加载的浏览器 bundle:宿主半侧扫描已启用的 Loader 条目并组合启动图,可用的 Web 载体通过 `/plugins` 提供每个 bundle,由 shell 持有的载体则通过 `fetchBundle()` 分派完全相同的 bundle 响应。浏览器半侧按需惰性加载这些 bundle。插件 bundle 惰性执行——运行 bundle 只注册 factory,模块副作用在物化时运行——因此插件首次被使用之前什么都不会运行。这里的一切都是浏览器内核机制;模型永远看不到它。 ## 目录 @@ -71,13 +71,13 @@ node 半侧会在发布前快照每个客户端 bundle 及其现有 source map ### 启动清单注入 -宿主 tap 索引渲染,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。 +宿主贡献结构化 index 行,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | node 半侧:`ClientModuleRegistry`、扫描、产物快照、combo 路由、索引 tap | +| [`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) | 协议类型与启动清单解析 | diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index e8c6770c95..c823657be5 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -516,7 +516,7 @@ window.__ModuleLoader__={ * boot activation audit reports it). */ export class ClientModuleRegistry extends Service { - static inject = ['webServer', 'loader'] + static inject = ['loader'] private readonly table = new Map() private readonly sources = new Map() @@ -537,7 +537,7 @@ export class ClientModuleRegistry extends Service { /** * Build the service: subscribe, seed, and run the activation flush. - * @param ctx - plugin context carrying webServer and loader. + * @param ctx - plugin context carrying Loader and an optional Web carrier. */ constructor(ctx: Context) { super(ctx, 'clientModules') @@ -567,10 +567,14 @@ export class ClientModuleRegistry extends Service { throw new ClientPackageCompositionError(failures) } - ctx.effect( - () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), - 'client-modules: bundle route', - ) + const registerWebCarrier = (webCtx: Context): void => { + webCtx.effect( + () => webCtx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), + 'client-modules: bundle route', + ) + } + if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier) + else registerWebCarrier(ctx) ctx.on('webserver/index-inject', (table) => { table.push(...bootInjections(this.composed)) }) @@ -593,6 +597,22 @@ export class ClientModuleRegistry extends Service { return this.table.get(id)?.meta.clientPath } + /** + * Serve an advertised revisioned bundle or source map without a Web server. + * Unknown URLs return 404, unsupported methods return 405, and `HEAD` + * returns the same immutable headers without a body. + * @param request - shell-carrier request for a `/plugins` resource. + * @returns the exact response also exposed by the optional Web route. + */ + fetchBundle(request: Request): Response { + const resource = this.bundleResource(request.method, request.url) + const body = resource.body === undefined ? null : Uint8Array.from(resource.body) + return new Response(body, { + status: resource.status, + ...(resource.headers === undefined ? {} : { headers: resource.headers }), + }) + } + /** * Filesystem baseline captured before an entry's current bytes were read. * HMR compares it with the live files when installing a watch, so a write @@ -984,28 +1004,32 @@ export class ClientModuleRegistry extends Service { this.notifyGraphChanged() } - private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => { - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) - res.end() - return - } - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const requestUrl = new URL(req.url ?? '/', 'http://x') + private bundleResource(method: string | undefined, url: string): { + status: number + headers?: Record + body?: Buffer + } { + if (method !== 'GET' && method !== 'HEAD') return { status: 405 } + const requestUrl = new URL(url, 'http://x') const resourceUrl = `${requestUrl.pathname}${requestUrl.search}` const response = this.responses.get(resourceUrl) ?? this.previousBatchResponses.get(resourceUrl) if (response !== undefined) { - res.writeHead(200, { - 'content-type': response.contentType, - 'cache-control': IMMUTABLE_CACHE, - }) - res.end(req.method === 'HEAD' ? undefined : response.body) - return + return { + status: 200, + headers: { 'content-type': response.contentType, 'cache-control': IMMUTABLE_CACHE }, + ...(method === 'HEAD' ? {} : { body: response.body }), + } } // Anything else under /plugins (including unadvertised combinations and // /plugins/events when the HMR row is absent) is an unknown resource. - res.writeHead(404) - res.end() + return { status: 404 } + } + + private readonly serveBundle = (req: IncomingMessage, res: ServerResponse): void => { + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ + const response = this.bundleResource(req.method, req.url ?? '/') + res.writeHead(response.status, response.headers) + res.end(response.body) } } diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 5553c94c8e..4f06360ff6 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -628,6 +628,10 @@ describe('client bundle activation', () => { expect(batchScript.status).toBe(200) expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable') expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapUrl(batch.url)}`) + const shellResponse = service.fetchBundle(new Request(`dsh-app://app${batch.url}`)) + expect(shellResponse.status).toBe(200) + expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable') + expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8')) expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0) expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405) const batchMap = await routeRequest(route, mapUrl(batch.url)) diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 3ab00ca5e7..b7b1bb883e 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -13,7 +13,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as LlmRetry from '@deepseek-ai/dsh-llm-retry' import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -151,9 +150,6 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - // AgentLoop and TokenMeter both declare the registry as a required - // injection; mount it before either activates. - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) @@ -317,7 +313,6 @@ describe('context-overflow recovery across the real loop and compaction-basic', const adapter = new OverflowRecoveryAdapter(delivery) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) ctx.llm.registerAdapter(['mock'], adapter) @@ -396,7 +391,6 @@ describe('context-overflow recovery across the real loop and compaction-basic', const adapter = new OverflowRecoveryAdapter('thrown', true) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LlmRetry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 5e295baf0a..e7425500f1 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -104,7 +104,6 @@ async function loopHarness(): Promise { await ctx.plugin(AgentInvariant) await ctx.plugin(AgentLoopInvariant) await ctx.plugin(CompactionInvariant) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TokenMeter) const adapter = new TextAdapter() diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 5a3eda934e..91e4df65bf 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", diff --git a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts index 6e9d89c040..7e35bdee21 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.e2e.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.e2e.ts @@ -4,14 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions' import { candidateScopeKey } from '../src/render.ts' @@ -38,12 +34,9 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { await mkdir(join(workdir, '.git'), { recursive: true }) await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`) ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt, { personaPrefix: 'Answer the user exactly and concisely.' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(ctx, { + systemPrompt: { personaPrefix: 'Answer the user exactly and concisely.' }, + }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 28669e88d0..7e3586faeb 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -1,15 +1,15 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { tmpdir } from 'node:os' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, ToolCallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, SessionSeq, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId, SessionSeq, type SessionEvent, type SurfaceIntent, type UserMessage } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -43,11 +43,27 @@ import { import { resolveConfig } from '../src/config.ts' import { candidateScopeKey, renderInstructionChanges, renderWorkspaceInstructionSet, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE } from '../src/render.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' /** Per-candidate reconciliation scope key: directory paired with the file name. */ const sk = (directory: string, candidateName: string): string => candidateScopeKey(directory, candidateName) const testToolSignal = new AbortController().signal +const isolatedInboxCtx = new Context() +await mountAgentLoopTestDependencies(isolatedInboxCtx) +const isolatedAgentLoop = await mountAgentLoopTestHarness(isolatedInboxCtx) +let nextStubSession = 1 +afterAll(() => isolatedInboxCtx.fiber.dispose()) + +type TestAgent = Agent + +/** Admit one test Agent's pending input through the production loop driver. */ +function claimInbox(agent: Agent, target: 'next-turn' | 'next-step'): UserMessage[] { + return isolatedAgentLoop.claim(agent, target, 1) +} const requestTimeoutMs = process.platform === 'win32' ? 5_000 : 1_000 async function tempRepo(): Promise { @@ -188,26 +204,30 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace return mountWorkspaceContextPlugin(ctx, config) } -function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Agent { - const id = SessionId('s1') - const session = Session.create(id, seed, cwd === undefined - ? undefined - : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd, isSeeded: false }) - return { - ctx: new Context(), - id: SessionId('a1'), - options: {}, - session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - send: () => {}, - followup: () => {}, - steer: () => {}, - inject: () => { throw new Error('agent-instructions must append directly to the open step') }, - cancel() {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), +async function stubAgent(cwd?: string, seed: readonly SessionEvent[] = []): Promise { + const id = SessionId(`agent-instructions-${String(nextStubSession++)}`) + const agent = await isolatedAgentLoop.create( + id, + {}, + cwd === undefined ? {} : { cwd }, + ) + const append = agent.session.append.bind(agent.session) as unknown as ( + type: SessionEvent['type'], + data: SessionEvent['data'], + opts?: Partial, + ) => SessionEvent + for (const event of seed) { + if ('surfaceOp' in event || 'sourceEventSeqs' in event) { + append(event.type, event.data, { + ...event.surfaceOp === undefined ? {} : { surfaceOp: event.surfaceOp }, + ...event.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: event.sourceEventSeqs }, + }) + } else { + append(event.type, event.data) + } } + if (seed.at(-1)?.type !== 'session/end-seed') agent.session.append('session/end-seed', {}) + return agent } function stubToolExecution( @@ -255,10 +275,10 @@ function baselineEvents(agent: Agent): SessionEvent[] { && event.data.source.baseline === true) } -async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise { +async function appendAdditionalContexts(ctx: Context, agent: TestAgent): Promise { await syncedWorkspaceContext(ctx, agent) let lastSeq: SessionSeq | undefined - for (const claimed of agent.inbox.claim('next-step', 1)) { + for (const claimed of claimInbox(agent, 'next-step')) { if (claimed.source.kind !== 'agent-instructions') continue const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' }) ctx.emit('session/event', agent.session, event) @@ -269,14 +289,14 @@ async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise() -async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { +async function composeBaselinePrefix(ctx: Context, agent: TestAgent): Promise { const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), ) - const claimed = agent.inbox.claim('next-step', 1) + const claimed = claimInbox(agent, 'next-step') const decision = await agentEvents(ctx, agent).waterfall( 'agent/pre-step', { messages: claimed, turn: 1, step: 2, signal }, @@ -492,7 +512,7 @@ describe('workspace context instruction discovery', () => { await symlink(join(outside, 'shared.md'), join(root, 'AGENTS.md')) const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1019,7 +1039,7 @@ describe('workspace context request injection', () => { callId: ToolCallId('missing-turn-boundary'), name: 'read', arguments: { file_path: 'file.txt' }, - agent: stubAgent('/virtual/repo'), + agent: await stubAgent('/virtual/repo'), signal: testToolSignal, }) @@ -1036,7 +1056,7 @@ describe('workspace context request injection', () => { const ctx = new Context() try { await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) - const agent = stubAgent('/virtual/repo') + const agent = await stubAgent('/virtual/repo') await composeBaselinePrefix(ctx, agent) @@ -1054,7 +1074,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1093,7 +1113,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await composeBaselinePrefix(ctx, agent) const second = await composeBaselinePrefix(ctx, agent) @@ -1115,12 +1135,12 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(root, original.session.snapshotEvents()) + const firstResume = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(root, firstResume.session.snapshotEvents()) + const secondResume = await stubAgent(root, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(firstResume)).toHaveLength(1) @@ -1143,11 +1163,11 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await composeBaselinePrefix(ctx, original) fs.throwOnStat.add(join(root, 'AGENTS.md')) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1170,12 +1190,12 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'package rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) - const original = stubAgent(cwd) + const original = await stubAgent(cwd) await composeBaselinePrefix(ctx, original) - const firstResume = stubAgent(cwd, original.session.snapshotEvents()) + const firstResume = await stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, firstResume) - const secondResume = stubAgent(cwd, firstResume.session.snapshotEvents()) + const secondResume = await stubAgent(cwd, firstResume.session.snapshotEvents()) await composeBaselinePrefix(ctx, secondResume) expect(baselineEvents(secondResume)).toHaveLength(1) @@ -1199,11 +1219,11 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'root '.repeat(200)) const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) - const original = stubAgent(cwd) + const original = await stubAgent(cwd) await composeBaselinePrefix(ctx, original) await write(join(cwd, 'AGENTS.md'), 'package rule') - const resumed = stubAgent(cwd, original.session.snapshotEvents()) + const resumed = await stubAgent(cwd, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) expect(baselineEvents(resumed)).toHaveLength(1) @@ -1232,7 +1252,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'agents rule') await write(join(root, 'CLAUDE.md'), 'claude rule') await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await composeBaselinePrefix(originalCtx, original) await mountWorkspaceContext(resumedCtx, { @@ -1240,7 +1260,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], }) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1259,7 +1279,7 @@ describe('workspace context request injection', () => { : []) expect(new Set(baselineIdentities).size).toBe(2) - const repeated = stubAgent(root, resumed.session.snapshotEvents()) + const repeated = await stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1285,7 +1305,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const original = stubAgent(root) + const original = await stubAgent(root) await composeBaselinePrefix(agentsCtx, original) await mountWorkspaceContext(claudeCtx, { @@ -1293,7 +1313,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.md'], }) - const claudeResume = stubAgent(root, original.session.snapshotEvents()) + const claudeResume = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(claudeCtx, claudeResume) const claudeBaseline = baselineEvents(claudeResume).at(-1) expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions' @@ -1308,7 +1328,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['AGENTS.md'], }) - const restored = stubAgent(root, claudeResume.session.snapshotEvents()) + const restored = await stubAgent(root, claudeResume.session.snapshotEvents()) await composeBaselinePrefix(restoredCtx, restored) const restoredBaseline = baselineEvents(restored).at(-1) expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions' @@ -1335,7 +1355,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'agents rule') await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await composeBaselinePrefix(originalCtx, original) await mountWorkspaceContext(resumedCtx, { @@ -1343,7 +1363,7 @@ describe('workspace context request injection', () => { maxBytes: 65536, instructionFileCandidates: ['POLICY.md'], }) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, resumed) const baselines = baselineEvents(resumed) @@ -1357,7 +1377,7 @@ describe('workspace context request injection', () => { { action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }, ]) - const repeated = stubAgent(root, resumed.session.snapshotEvents()) + const repeated = await stubAgent(root, resumed.session.snapshotEvents()) await composeBaselinePrefix(resumedCtx, repeated) expect(baselineEvents(repeated)).toHaveLength(2) } finally { @@ -1376,7 +1396,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, @@ -1387,9 +1407,9 @@ describe('workspace context request injection', () => { await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) - const claimed = resumed.inbox.claim('next-step', 1) + const claimed = claimInbox(resumed, 'next-step') const decision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, @@ -1421,7 +1441,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'old repo rule') const ctx = new Context() const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await agentEvents(ctx, original).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, @@ -1433,9 +1453,9 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'new repo rule') await fiber.dispose() await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) - const staleClaim = resumed.inbox.claim('next-step', 1) + const staleClaim = claimInbox(resumed, 'next-step') const staleDecision = await agentEvents(ctx, resumed).waterfall( 'agent/pre-step', { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, @@ -1474,7 +1494,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'AGENTS.md'), 'repo rule') await mountWorkspaceContext(originalCtx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await agentEvents(originalCtx, original).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, @@ -1486,9 +1506,9 @@ describe('workspace context request injection', () => { await originalCtx.fiber.dispose() if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(resumedCtx, { dshHome: home, maxBytes }) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' }) - const claimed = resumed.inbox.claim('next-step', 1) + const claimed = claimInbox(resumed, 'next-step') const decision = await agentEvents(resumedCtx, resumed).waterfall( 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(requestTimeoutMs) }, @@ -1513,7 +1533,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'stale nested instructions' }], source: { @@ -1547,7 +1567,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'stale nested instructions' }], source: { @@ -1586,7 +1606,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const prompt = createUserMessage({ content: [{ type: 'text', text: 'current prompt' }], source: { kind: 'user' }, @@ -1621,7 +1641,7 @@ describe('workspace context request injection', () => { await write(join(home, 'AGENTS.md'), 'global rule') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) await write(join(home, 'AGENTS.md'), 'updated global rule') @@ -1647,7 +1667,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const downstream = { kind: 'reject' as const } const decision = await agentEvents(ctx, agent).waterfall( @@ -1674,7 +1694,7 @@ describe('workspace context request injection', () => { await write(join(root, 'file.txt'), 'hello') const ctx = new Context() const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) // Hot remount over the live session: the durable baseline remains @@ -1711,7 +1731,7 @@ describe('workspace context request injection', () => { const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) const fiber = await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] expect(baseline).toBeDefined() @@ -1744,7 +1764,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'first post-compaction request rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] expect(baseline).toBeDefined() @@ -1787,13 +1807,13 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'old root rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await composeBaselinePrefix(ctx, original) // The first resumed pre-step retains the compatible visible baseline and // appends only the offline file transition needed to reach current state. await write(join(root, 'AGENTS.md'), 'new root rule after offline edit') - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) // Resume announces its lifecycle start before the first step. agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' }) @@ -1827,7 +1847,7 @@ describe('workspace context request injection', () => { await write(join(cwd, 'AGENTS.md'), 'package rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) - const agent = stubAgent(cwd) + const agent = await stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -1859,7 +1879,7 @@ describe('workspace context request injection', () => { } }) - const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) + const prefix = await composeBaselinePrefix(ctx, await stubAgent(root)) expect(prefix).toHaveLength(2) expect(blocksText(prefix[0]?.content)).toContain('Instructions from: AGENTS.md') @@ -1879,7 +1899,7 @@ describe('workspace context request injection', () => { await write(join(root, 'file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) await write(join(root, 'AGENTS.md'), 'new root rule with more detail') @@ -1908,7 +1928,7 @@ describe('workspace context request injection', () => { await write(join(root, 'file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) await rm(join(root, 'AGENTS.md')) @@ -1934,7 +1954,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'shared root and global rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: root, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1954,7 +1974,7 @@ describe('workspace context request injection', () => { await write(join(root, 'file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -1978,7 +1998,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'x'.repeat(1000)) const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2013,7 +2033,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2036,7 +2056,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2079,7 +2099,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) - const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) + const prefix = await composeBaselinePrefix(ctx, await stubAgent(root)) expect(prefix).toEqual([]) expect(fs.readTargets).toEqual([]) @@ -2104,7 +2124,7 @@ describe('workspace context request injection', () => { fs.omitSizes.add(instructionPath) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 }) - const prefix = await composeBaselinePrefix(ctx, stubAgent(root)) + const prefix = await composeBaselinePrefix(ctx, await stubAgent(root)) expect(prefix).toEqual([]) expect(fs.readTargets).toEqual([instructionPath, instructionPath]) @@ -2128,7 +2148,7 @@ describe('workspace context request injection', () => { await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) const controller = new AbortController() const reason = new Error('cancel prefix') - const pending = agentEvents(ctx, stubAgent(root)).waterfall( + const pending = agentEvents(ctx, await stubAgent(root)).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal }, () => Promise.resolve({ kind: 'enter' as const, messages: [] }), @@ -2160,7 +2180,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' }) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2186,7 +2206,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2209,7 +2229,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2232,7 +2252,7 @@ describe('workspace context request injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'AGENTS.md')) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2254,7 +2274,7 @@ describe('workspace context request injection', () => { fs.throwOnStat.add(join(root, 'AGENTS.md')) fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2279,7 +2299,7 @@ describe('workspace context request injection', () => { fs.throwOnStat.add(join(root, '.git')) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2301,8 +2321,8 @@ describe('workspace context request injection', () => { await write(join(repoB, 'AGENTS.md'), 'repo B only') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agentA = stubAgent(repoA) - const agentB = stubAgent(repoB) + const agentA = await stubAgent(repoA) + const agentB = await stubAgent(repoB) await composeBaselinePrefix(ctx, agentA) await composeBaselinePrefix(ctx, agentB) @@ -2329,7 +2349,7 @@ describe('workspace context request injection', () => { const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) - const agent = stubAgent(cwd) + const agent = await stubAgent(cwd) await composeBaselinePrefix(ctx, agent) @@ -2350,7 +2370,7 @@ describe('workspace context request injection', () => { const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: '/' }) await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2371,7 +2391,7 @@ describe('workspace context request injection', () => { const ctx = new Context() const fiber = await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await fiber.dispose() - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2390,7 +2410,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 0 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2409,7 +2429,7 @@ describe('workspace context request injection', () => { await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: -1 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2427,7 +2447,7 @@ describe('workspace context request injection', () => { await mkdir(join(root, '.git'), { recursive: true }) const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) @@ -2540,6 +2560,7 @@ describe('dynamic nested workspace context injection', () => { ]) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) @@ -2596,8 +2617,8 @@ describe('dynamic nested workspace context injection', () => { expect(state.versions).toEqual(new Map()) }) - it('creates and releases version-cache state only for non-empty updates', () => { - const agent = stubAgent('/repo') + it('creates and releases version-cache state only for non-empty updates', async () => { + const agent = await stubAgent('/repo') const cache: InstructionVersionCache = new WeakMap() const change = { action: 'set' as const, scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md', digest: 'digest' } applyInstructionVersionUpdates(agent.session, [], cache) @@ -2629,7 +2650,7 @@ describe('dynamic nested workspace context injection', () => { callId: ToolCallId('cancelled-dynamic-read'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, - agent: stubAgent(root), + agent: await stubAgent(root), signal: controller.signal, }) @@ -2659,7 +2680,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const result = await ctx.tools.execute({ signal: testToolSignal, @@ -2704,7 +2725,7 @@ describe('dynamic nested workspace context injection', () => { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'nested package rule') await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const controller = new AbortController() ctx.emit('tools/result', stubToolExecution({ @@ -2738,7 +2759,7 @@ describe('dynamic nested workspace context injection', () => { maxBytes: 65536, instructionFileCandidates: ['CLAUDE.local.md', 'AGENTS.md', 'CLAUDE.md'], }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -2771,7 +2792,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -2814,7 +2835,7 @@ describe('dynamic nested workspace context injection', () => { maxBytes: 65536, localInstructionFileCandidates: [], }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -2842,7 +2863,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, @@ -2885,7 +2906,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, @@ -2922,7 +2943,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -2968,8 +2989,8 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(ToolFs) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const firstAgent = stubAgent(root) - const secondAgent = stubAgent(root) + const firstAgent = await stubAgent(root) + const secondAgent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: firstAgent, @@ -3000,7 +3021,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3043,7 +3064,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3081,7 +3102,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3113,7 +3134,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/CLAUDE.md'), { type: 'file', content: 'nested rule' }) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - const agent = stubAgent(root) + const agent = await stubAgent(root) const agentsScope = sk('pkg', 'AGENTS.md') const loaded = baselineInstructionState([{ absolutePath: join(root, 'pkg/AGENTS.md'), @@ -3183,7 +3204,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const rootScope = sk('.', 'AGENTS.md') const loaded = baselineInstructionState([{ absolutePath: join(root, 'AGENTS.md'), @@ -3228,7 +3249,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - const agent = stubAgent(root) + const agent = await stubAgent(root) agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'removed nested instructions' }], source: { @@ -3268,7 +3289,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'shared rule' }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const resolved = resolveConfig({ dshHome: root, maxBytes: 65536, @@ -3303,7 +3324,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3340,7 +3361,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3378,7 +3399,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3418,7 +3439,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3458,7 +3479,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, @@ -3503,7 +3524,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, @@ -3534,7 +3555,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('read-before-resume'), @@ -3543,7 +3564,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const resumed = stubAgent(root, agent.session.snapshotEvents()) + const resumed = await stubAgent(root, agent.session.snapshotEvents()) const afterResume = await ctx.tools.execute({ signal: testToolSignal, @@ -3570,14 +3591,14 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original, }) await appendAdditionalContexts(ctx, original) await write(join(root, 'pkg/AGENTS.md'), 'new nested rule after resume') - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) await composeBaselinePrefix(ctx, resumed) @@ -3601,7 +3622,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('read-before-compact'), @@ -3653,7 +3674,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await composeBaselinePrefix(ctx, agent) const baseline = baselineEvents(agent)[0] expect(baseline).toBeDefined() @@ -3713,7 +3734,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/sub/file.txt'), 'subtree file') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('read-package'), @@ -3751,7 +3772,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/sub/file.txt'), 'subtree file') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 700 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('read-subtree-omitting-parent'), @@ -3788,7 +3809,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) agent.session.append('user/message', createUserMessage({ content: [ { type: 'reasoning', text: 'Additional instructions from: pkg/AGENTS.md' }, @@ -3839,7 +3860,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const rootResult = await ctx.tools.execute({ signal: testToolSignal, @@ -3882,7 +3903,7 @@ describe('dynamic nested workspace context injection', () => { fs.throwOnRead.add(nested) await ctx.plugin(ToolFs) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const result = await ctx.tools.execute({ signal: testToolSignal, @@ -3914,7 +3935,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: { @@ -3976,7 +3997,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'blocked downstream' }], @@ -4023,7 +4044,7 @@ describe('dynamic nested workspace context injection', () => { : downstream }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const blocked = await ctx.tools.execute({ signal: testToolSignal, @@ -4092,7 +4113,7 @@ describe('dynamic nested workspace context injection', () => { : downstream }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const blocked = await ctx.tools.execute({ signal: testToolSignal, @@ -4119,7 +4140,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const turnStart = agent.session.append('turn/start', { turn: 1 }) ctx.emit('session/event', agent.session, turnStart) const stepStart = agent.session.append('step/start', { turn: 1, step: 1 }) @@ -4185,7 +4206,7 @@ describe('dynamic nested workspace context injection', () => { const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' }) - const agent = stubAgent(root) + const agent = await stubAgent(root) agent.session.append('turn/start', { turn: 1 }) agent.session.append('step/start', { turn: 1, step: 1 }) agent.session.append('step/end', { turn: 1, step: 1 }) @@ -4215,7 +4236,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(RecordingFileSystem) await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem - const agent = stubAgent('/') + const agent = await stubAgent('/') const plainResult = { callId: ToolCallId('plain'), content: [], isError: false as const, value: null } const aborted = new AbortController() aborted.abort(new Error('cancelled')) @@ -4265,7 +4286,7 @@ describe('dynamic nested workspace context injection', () => { await mountWorkspaceContextPlugin(ctx, { maxBytes: 65536 }) const fs = ctx.fs as RecordingFileSystem const root = resolve('/') - const agent = stubAgent(root) + const agent = await stubAgent(root) const failure = new Error('projection failed') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) fs.entries.set(join(root, '.git'), { type: 'directory' }) @@ -4303,7 +4324,7 @@ describe('dynamic nested workspace context injection', () => { callId: ToolCallId('read-with-disabled-budget'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, - agent: stubAgent(root), + agent: await stubAgent(root), }) expect(result.isError).toBe(false) @@ -4329,7 +4350,7 @@ describe('dynamic nested workspace context injection', () => { fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' }) await ctx.plugin(ToolFs) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 20 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = await ctx.tools.execute({ signal: testToolSignal, @@ -4369,7 +4390,7 @@ describe('dynamic nested workspace context injection', () => { callId: ToolCallId('read-missing'), name: 'read', arguments: { file_path: join('pkg', 'missing.txt') }, - agent: stubAgent(root), + agent: await stubAgent(root), }) expect(result.isError).toBe(true) @@ -4390,7 +4411,7 @@ describe('dynamic nested workspace context injection', () => { const ctx = new Context() const fiber = await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await fiber.dispose() - const agent = stubAgent(root) + const agent = await stubAgent(root) const result = await ctx.tools.execute({ signal: testToolSignal, @@ -4426,7 +4447,7 @@ describe('workspace context inbox synchronization', () => { await write(join(root, 'AGENTS.md'), 'duplicate baseline') const ctx = new Context() await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await syncWorkspaceContext(ctx, agent) const desired = agent.inbox.nextStep[0]! agent.inbox.append('next-step', createUserMessage({ content: desired.content, source: desired.source })) @@ -4451,7 +4472,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, '.git'), { type: 'directory' }) fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 1 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) ctx.emit('tools/result', stubToolExecution({ signal: testToolSignal, callId: ToolCallId('tiny-budget-touch'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, @@ -4479,7 +4500,7 @@ describe('workspace context inbox synchronization', () => { await write(join(root, 'pkg/file.txt'), 'file') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('pending-v1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, @@ -4527,7 +4548,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, callId: ToolCallId('projected-before-abort'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent, @@ -4567,7 +4588,7 @@ describe('workspace context inbox synchronization', () => { fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' }) fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(root) + const agent = await stubAgent(root) const first = stubToolExecution({ signal: testToolSignal, callId: ToolCallId('concurrent-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent, @@ -4605,13 +4626,13 @@ describe('workspace context inbox synchronization', () => { await write(join(root, 'b/file.txt'), 'b') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const original = stubAgent(root) + const original = await stubAgent(root) await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncWorkspaceContext(ctx, original) - const resumed = stubAgent(root, original.session.snapshotEvents()) + const resumed = await stubAgent(root, original.session.snapshotEvents()) await ctx.tools.execute({ signal: testToolSignal, @@ -4640,9 +4661,9 @@ describe('workspace context inbox synchronization', () => { await write(join(root, 'pkg/file.txt'), 'file') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = stubAgent(join(root, 'pkg')) + const agent = await stubAgent(join(root, 'pkg')) await syncedWorkspaceContext(ctx, agent) - const claimed = agent.inbox.claim('next-step', 1) + const claimed = claimInbox(agent, 'next-step') await write(join(root, 'pkg/AGENTS.md'), 'new claimed rule with more detail') const downstream = { kind: 'enter' as const, messages: claimed } diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 63d1bcb2b0..5d47095e5b 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,11 +4,11 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import { createUserMessage, ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { unsupportedInbox, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as timeContext from '@deepseek-ai/dsh-time-context' import type { Config } from '@deepseek-ai/dsh-time-context' @@ -38,11 +38,11 @@ async function mount(config: Config = {}) { } function sessionAgent(session: Session, id = 'agent'): Agent { - return { + const agent: Agent = { id: SessionId(id), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'running', ctx: new Context(), send: () => {}, @@ -53,6 +53,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + return agent } function openMessageTurn(session: Session, turn: number, clientTimeZone?: string): void { @@ -139,7 +140,6 @@ class ScriptedAdapter extends LlmAdapter { async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(timeContext, config) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index c8ba14da68..43d6c7e956 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index cc1c4985d3..bc9f9f0ed5 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -1,13 +1,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' import type { Config } from '@deepseek-ai/dsh-tmux-context' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const SIGNAL = new AbortController().signal @@ -94,11 +95,11 @@ async function mount( } function sessionAgent(session: Session, id = 'agent'): Agent { - return { + const agent: Agent = { id: SessionId(id), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'running', ctx: new Context(), send: () => {}, @@ -109,6 +110,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + return agent } function openMessageTurn(session: Session, turn: number): void { diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index b1cc6893b2..6cda32c516 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 55966a4b0eed0c1cc3484314e809de79341072de -README.zh.md: 1d27bf3743f54d8fe66a58e75565fc85d94ae1ed +README.md: b24151c8fb512b144b15c017b662b0b2a57532fc +README.zh.md: d4c3b511426ce06c955d36d3f0f3852fe459ce0b diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 55966a4b0e..b24151c8fb 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -68,6 +68,8 @@ const handle = await ctx.agents.create({ }) ``` +Every inbox mutation commits one normalized `agent/inbox/spliced` event. The projection registry folds that event synchronously, so the live projection reflects the splice when `Session.append()` returns. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome and emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists. Consumers that need a removed message use the claimed or discarded notification instead of depending on a pre-splice `session/event` view. + ### What a step does Each step sends the agent's rendered system prompt, its visible tool schemas, and the session's derived history; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user. @@ -98,6 +100,7 @@ The loop deep-freezes each derived message identity on its first request and reu |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentLoop` service, config schema, declarative agent startup, factory registration | | [`src/agent.ts`](src/agent.ts) | The concrete `ReactLoopAgent` driver: inbox, turn/step machine, cancellation | +| [`src/inbox.ts`](src/inbox.ts) | Package-internal `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state | | [`src/tool-calls.ts`](src/tool-calls.ts) | Tool scheduling: exclusive barriers and the bounded parallel pool | | [`src/runtime-context.ts`](src/runtime-context.ts) | Per-step runtime-context snapshot handling | | [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` | @@ -113,7 +116,7 @@ The loop is the production acquisition point for session write handles. When `ct ### Turn and step flow -The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. +The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Its package-internal `ReactLoopInbox` constructor registers the standard `inbox` projection on the agent scope, then uses that projection for structural commands and loop-only claims. Registry reference counting keeps the shared key active until the last agent scope unloads. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. `agent/pre-step` decides what enters the step. An entered decision appends its complete `user/message` batch before the driver can claim again, while a rejected decision appends none. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable `assistant/chunk`, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor citing its chunk seqs, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results, and result context remain model-ordered. ### Failure and cancellation diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 1d27bf3743..d4c3b51142 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -68,6 +68,8 @@ const handle = await ctx.agents.create({ }) ``` +每次 inbox 变更都会提交一条规范化的 `agent/inbox/spliced` 事件。投影注册表会同步折叠该事件,因此 `Session.append()` 返回时,实时投影已经反映该 splice。插入、编辑、移除、领取与取消都通过同一组标准 splice 坐标回放。普通删除携带 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`;领取使用不带 outcome 的纯删除,并发出 `agent/inbox/claimed`。每次插入都会发出 `agent/inbox/inserted { message }`。`MessageId` 在两个待处理列表之间保持唯一。需要被移除消息的消费方应使用 claimed 或 discarded 通知,而不依赖 splice 前的 `session/event` 投影视图。 + ### 一个步骤做什么 每个步骤都会发送该 agent 渲染后的系统提示词、其可见工具 schema 与会话的派生历史;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。 @@ -98,6 +100,7 @@ const handle = await ctx.agents.create({ |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`AgentLoop` 服务、配置 schema、声明式 agent 启动、工厂注册 | | [`src/agent.ts`](src/agent.ts) | 具体 `ReactLoopAgent` 驱动器:收件箱、轮次/步骤状态机、取消 | +| [`src/inbox.ts`](src/inbox.ts) | 包内部的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 | | [`src/tool-calls.ts`](src/tool-calls.ts) | 工具调度:独占屏障与有界并行池 | | [`src/runtime-context.ts`](src/runtime-context.ts) | 每步骤 runtime-context 快照处理 | | [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` | @@ -113,7 +116,7 @@ const handle = await ctx.agents.create({ ### 轮次与步骤流程 -驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 +驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。其包内部 `ReactLoopInbox` 构造函数在 agent 作用域上注册标准 `inbox` 投影,随后将该投影用于结构化命令与仅供 loop 使用的领取操作。注册表引用计数会使共享 key 持续有效,直至最后一个 agent 作用域卸载。在轮次边界,驱动器先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。`agent/pre-step` 决定什么进入该步骤。进入步骤的决定会在驱动器再次领取消息前追加完整的 `user/message` 批次,被拒绝的决定则不追加任何消息。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 `assistant/chunk` 之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个引用其分片 seq 的 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。 ### 失败与取消 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 634a1a52de..0bb02706ed 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -15,7 +15,7 @@ import type { PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' -import { Inbox, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import { LlmError, @@ -32,6 +32,7 @@ import { joinContextSections, renderContextSections, renderPrompt } from '@deeps import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' +import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' import { AssistantStreamAttempt } from './assistant-stream.ts' import { executeToolCalls } from './tool-calls.ts' @@ -68,7 +69,7 @@ function requestProposal(header: EpochHeader): LlmCallConfig { /** Drives one session through turn and step boundaries. */ export class ReactLoopAgent implements Agent { - readonly inbox: Inbox + readonly inbox: ReactLoopInbox private phase: Phase private activityDone: Promise = Promise.resolve() @@ -97,16 +98,12 @@ export class ReactLoopAgent implements Agent { public readonly session: Session, ) { this.dispatch = agentEvents(loopCtx, this) - this.inbox = new Inbox(session, { - inserted: (message) => { this.dispatch.emit('agent/inbox/inserted', { message }) }, - discarded: (message) => { this.dispatch.emit('agent/inbox/discarded', { message }) }, - claimed: (message, turn) => { this.dispatch.emit('agent/inbox/claimed', { message, turn }) }, - }) + this.scope = createScope(loopCtx, this) + this.ctx = this.scope.ctx.extend({ agent: this }) + this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch) /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */ const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0 this.phase = { kind: 'idle', lastTurn } - this.scope = createScope(loopCtx, this) - this.ctx = this.scope.ctx.extend({ agent: this }) this.runtimeContext = new RuntimeContextProjection(this.ctx, session) } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts new file mode 100644 index 0000000000..db89cd3072 --- /dev/null +++ b/packages/core/agent-loop/src/inbox.ts @@ -0,0 +1,247 @@ +/** + * Driver-owned durable agent inbox projection and command facade. + * + * @module @deepseek-ai/dsh-agent-loop/inbox + */ + +import type { MessageId } from '@deepseek-ai/dsh-llm' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' +import type { + AgentEventDispatch, + Inbox as InboxContract, + InboxState, + InboxTarget, + InboxWireState, +} from '@deepseek-ai/dsh-agent' +import { z } from 'zod' + +/** Wire validation for pending agent input reconstructed from durable inbox splices. */ +export const inboxProjectionSchema = z.object({ + 'next-turn': z.array(z.custom()).readonly(), + 'next-step': z.array(z.custom()).readonly(), +}).readonly() + +/** Standard fold that reconstructs pending input and rejects invalid durable splice history. */ +export const inboxProjectionDefinition = { + key: 'inbox', + stateSchema: inboxProjectionSchema, + init: (): InboxState => ({ 'next-turn': [], 'next-step': [] }), + apply(state: InboxState, event) { + if (event.type !== 'agent/inbox/spliced') return state + const splice = event.data + try { + const inbox = state[splice.target] + const removedCount = splice.removedCount ?? 0 + if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length + || !Number.isSafeInteger(removedCount) || removedCount < 0 + || splice.start + removedCount > inbox.length) { + throw new Error('invalid inbox splice') + } + const next = inbox.toSpliced(splice.start, removedCount, ...splice.inserted) + const ids = new Set() + for (const message of splice.target === 'next-turn' + ? [...next, ...state['next-step']] + : [...state['next-turn'], ...next]) { + if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) + ids.add(message.id) + } + return splice.target === 'next-turn' + ? { 'next-turn': next, 'next-step': state['next-step'] } + : { 'next-turn': state['next-turn'], 'next-step': next } + } catch (error: unknown) { + throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error }) + } + }, + wire: { + // The wire value is the fold state itself: every pending message already + // round-trips the session log as lossless JSON. Only the static type + // narrows to the JSON-safe projection table entry. + viewSchema: inboxProjectionSchema as unknown as z.ZodType, + view: (state: InboxState) => state as unknown as InboxWireState, + }, + stateVersion: 1, +} satisfies ProjectionDefinition<'inbox', InboxState> + +/** + * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused + * provider tests. + * @param projections - registry that owns the standard Inbox projection. + * @param session - session whose durable events store pending input. + * @param dispatch - agent-scoped notifications for Inbox lifecycle events. + */ +export class ReactLoopInbox implements InboxContract { + constructor( + private readonly projections: SessionProjectionRegistry, + private readonly session: Session, + private readonly dispatch: AgentEventDispatch, + ) { + this.projections.register(inboxProjectionDefinition) + } + + /** Prompts awaiting individual turns. */ + get nextTurn(): readonly UserMessage[] { + return this.current()['next-turn'] + } + + /** Input awaiting the next step boundary. */ + get nextStep(): readonly UserMessage[] { + return this.current()['next-step'] + } + + /** Whether either pending-message list contains work. */ + get hasPending(): boolean { + const state = this.current() + return state['next-turn'].length > 0 || state['next-step'].length > 0 + } + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void { + this.splice('next-step', 0, this.nextStep.length, []) + this.splice('next-turn', 0, this.nextTurn.length, []) + } + + /** + * Remove and return the complete batch proposed for one step. + * @param target - whether this boundary also consumes one queued turn. + * @param turn - turn that will own the claimed batch. + * @returns next-step input followed by the queued turn, when requested. + */ + claim(target: InboxTarget, turn: number): UserMessage[] { + const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) + if (target === 'next-turn') claimed.push(...this.mutate('next-turn', 0, 1, [], false)) + for (const message of claimed) this.dispatch.emit('agent/inbox/claimed', { message, turn }) + return claimed + } + + /** + * Append one message to a pending list. + * @param target - pending list to extend. + * @param message - message to append. + */ + append(target: InboxTarget, message: UserMessage): void { + this.splice(target, this.current()[target].length, 0, [message]) + } + + /** + * Prepend one message to a pending list. + * @param target - pending list to extend. + * @param message - message to prepend. + */ + prepend(target: InboxTarget, message: UserMessage): void { + this.splice(target, 0, 0, [message]) + } + + /** + * Replace one pending message in place. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean { + const location = this.locate(messageId) + if (location === undefined) return false + this.splice(location.target, location.index, 1, [newMessage]) + return true + } + + /** + * Remove one pending message. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean { + const location = this.locate(messageId) + if (location === undefined) return false + this.splice(location.target, location.index, 1, []) + return true + } + + /** + * Apply standard splice semantics and durably record the normalized result. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] { + return this.mutate(target, start, deleteCount, inserted, true) + } + + /** Locate one pending identity across both owned lists. */ + private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { + const state = this.current() + for (const target of ['next-turn', 'next-step'] as const) { + const index = state[target].findIndex(message => message.id === messageId) + if (index >= 0) return { target, index } + } + return undefined + } + + /** Read the current durable projection state. */ + private current(): InboxState { + const state = this.projections.stateOf(this.session, 'inbox') + /* v8 ignore next -- the constructor registers this key before any read */ + if (state === undefined) { + throw new Error( + `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`, + ) + } + return state + } + + /** Commit one normalized mutation and publish its live events. */ + private mutate( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + discardRemoved: boolean, + ): UserMessage[] { + const state = this.current() + const inbox = state[target] + const truncatedStart = Math.trunc(start) + const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart + const actualStart = offset < 0 + ? Math.max(inbox.length + offset, 0) + : Math.min(offset, inbox.length) + const truncatedDeleteCount = Math.trunc(deleteCount) + const actualDeleteCount = Math.min( + Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), + inbox.length - actualStart, + ) + if (actualDeleteCount === 0 && inserted.length === 0) return [] + const candidate = inbox.toSpliced(actualStart, actualDeleteCount, ...inserted) + const ids = new Set() + for (const message of target === 'next-turn' + ? [...candidate, ...state['next-step']] + : [...state['next-turn'], ...candidate]) { + if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) + ids.add(message.id) + } + const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined + const splice: SessionEventMap['agent/inbox/spliced'] = { + target, + start: actualStart, + ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }), + inserted, + ...(outcome === undefined ? {} : { outcome }), + } + const removed = inbox.slice(actualStart, actualStart + actualDeleteCount) + const event = this.session.append('agent/inbox/spliced', splice) + if (discardRemoved) { + for (const message of removed) this.dispatch.emit('agent/inbox/discarded', { message }) + } + for (const message of event.data.inserted) { + this.dispatch.emit('agent/inbox/inserted', { message }) + } + return removed + } +} diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 073ecae7b7..db25ec4e64 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -584,7 +584,9 @@ export class AgentLoop extends Service implements AgentFactory { // Disposal IS a disposed-cause cancel followed by quiescence. New work // sent after this point is the sender's bug — the registries are about // to drop the agent, so nothing should still hold it. + /* v8 ignore next -- Cordis effect teardown waits for synchronous setup before observing the machine slot. */ if (machine === undefined) await machineReady.promise + /* v8 ignore next -- setup failure untracks this disposer before resolving without a machine. */ if (machine !== undefined) { machine.cancel({ kind: 'disposed' }) await machine.whenIdle() @@ -617,15 +619,21 @@ export class AgentLoop extends Service implements AgentFactory { const untrack = this.ownership.track(dispose) let unfollowOwner: () => Promise | void try { - unfollowOwner = ownerCtx.effect(() => () => { - // Owner disposal owns the same quiescence boundary. Its teardown skips - // unregistering this already-running owner effect from inside itself. - if (disposing !== undefined) return - abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) - return dispose(true) + unfollowOwner = ownerCtx.effect(function* () { + machine = new ReactLoopAgent(loopCtx, id, options, session) + machineReady.resolve() + yield machine.scope.rawDispose + yield () => { + // Owner disposal owns the same quiescence boundary. Its teardown skips + // unregistering this already-running owner effect from inside itself. + if (disposing !== undefined) return + abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`)) + return dispose(true) + } }, `agentLoop.lifecycle(${id})`) /* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */ } catch (error: unknown) { + machineReady.resolve() untrack() callerSignal?.removeEventListener('abort', onCallerAbort) this.ownership.signal.removeEventListener('abort', onFactoryTeardown) @@ -642,8 +650,9 @@ export class AgentLoop extends Service implements AgentFactory { throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason)) } try { - const agent = machine = new ReactLoopAgent(loopCtx, id, options, session) - machineReady.resolve() + /* v8 ignore next -- a synchronous effect exhausts the generator before returning */ + if (machine === undefined) throw new Error(`agent "${id}" lifecycle did not construct its driver`) + const agent = machine assertLive() return { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index cb0ee6a933..68cb93f129 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -7,7 +7,6 @@ import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@d import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { ReactLoopAgent } from '../src/agent.ts' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' @@ -249,7 +248,11 @@ describe('abort during tool execution ends the turn', () => { ? [event.data.content] : [])) .toEqual([]) - expect(agent.inbox.nextStep.map(inboxText)) + expect(agent.session.snapshotEvents() + .flatMap(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-step' + ? [event.data.inserted.map(inboxText)] + : []) + .at(-1)) .toEqual(['accepted result context during disposal']) expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/start')) .toHaveLength(1) @@ -583,10 +586,11 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) - const seeded = ctx2.sessions.create(SessionId('forked'), { seed: agent.session.snapshotEvents() }) - const forked = new ReactLoopAgent( - ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, - ) + const { agent: forked } = await ctx2.agents.create({ + sessionId: SessionId('forked'), + seed: agent.session.snapshotEvents(), + agentOptions: { provider: 'mock', model: 'mock' }, + }) const turns: number[] = [] ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts new file mode 100644 index 0000000000..028c42ca5c --- /dev/null +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -0,0 +1,266 @@ +import { Context } from '@deepseek-ai/cordis' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { describe, expect, it } from 'vitest' +import { ReactLoopInbox } from '../src/inbox.ts' + +function unsupportedInbox(): Agent['inbox'] { + const rejectMutation = (): never => { + throw new Error('this test Agent does not support Inbox mutations') + } + return { + nextTurn: [], nextStep: [], clear: rejectMutation, append: rejectMutation, + prepend: rejectMutation, replace: rejectMutation, remove: rejectMutation, splice: rejectMutation, + } +} + +function stubAgent(rawId: string, overrides: Partial = {}): Agent { + const id = SessionId(rawId) + const session = overrides.session ?? Session.create(id) + const ctx = overrides.ctx ?? new Context() + return { + id, + options: {}, + session, + inbox: unsupportedInbox(), + status: 'idle', + ctx, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + ...overrides, + } +} + +async function inboxAgent(rawId: string): Promise<{ + ctx: Context + session: Session + agent: Agent + inbox: ReactLoopInbox +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const session = ctx.sessions.create(SessionId(rawId)) + const agent = stubAgent(rawId, { ctx, session }) + const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + Object.assign(agent, { inbox }) + return { ctx, session, agent, inbox } +} + +async function reconstructPersistedInbox( + rawId: string, + populate: (session: Session) => void, +): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId(rawId)) + populate(session) + await ctx.plugin(SessionProjectionRegistry) + const agent = stubAgent(rawId, { ctx, session }) + const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + try { + void inbox.nextTurn + } catch (error: unknown) { + if (error instanceof Error) return error + throw error + } + throw new Error('persisted inbox reconstruction unexpectedly succeeded') +} + +describe('ReactLoopInbox', () => { + it('registers the durable projection in its constructor', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const session = ctx.sessions.create(SessionId('inbox-projection')) + const pending = createUserMessage({ + content: [{ type: 'text', text: 'pending' }], + source: { kind: 'user' }, + }) + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, inserted: [pending], + }) + const agent = stubAgent('inbox-projection', { ctx, session }) + const dispatch = agentEvents(ctx, agent) + const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) + const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) + + expect(first.nextTurn).toEqual([pending]) + expect(second.nextTurn).toEqual([pending]) + expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({ + 'next-turn': [pending], + 'next-step': [], + }) + }) + + it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => { + const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => { + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, removedCount: 1, inserted: [], + }) + }) + expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0') + expect((outOfRange.cause as Error).message).toBe('invalid inbox splice') + + const pending = createUserMessage({ + content: [{ type: 'text', text: 'duplicate' }], + source: { kind: 'user' }, + }) + const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => { + session.append('agent/inbox/spliced', { + target: 'next-turn', start: 0, inserted: [pending], + }) + session.append('agent/inbox/spliced', { + target: 'next-step', start: 0, inserted: [pending], + }) + }) + expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1') + expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`) + }) + + it('projects inherited inbox events in a forked session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const parent = ctx.sessions.create(SessionId('inbox-fork-parent')) + const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent }) + const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent)) + const inherited = createUserMessage({ + content: [{ type: 'text', text: 'parent pending' }], + source: { kind: 'user' }, + }) + parentInbox.append('next-turn', inherited) + const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child')) + const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) + const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent)) + + expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length) + expect(childInbox.nextTurn).toEqual([inherited]) + + const own = createUserMessage({ + content: [{ type: 'text', text: 'child pending' }], + source: { kind: 'user' }, + }) + childInbox.append('next-turn', own) + expect(childInbox.nextTurn).toEqual([inherited, own]) + + }) + + it('updates the projection cell before session observers run', async () => { + const { ctx, session, inbox } = await inboxAgent('inbox-live-projection') + const pending = createUserMessage({ + content: [{ type: 'text', text: 'direct' }], + source: { kind: 'user' }, + }) + let observed: readonly UserMessage[] | undefined + ctx.on('session/event', (subject, event) => { + if (subject === session && event.type === 'agent/inbox/spliced') { + observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn'] + } + }) + + inbox.append('next-turn', pending) + + expect(observed).toEqual([pending]) + expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({ + 'next-turn': [pending], 'next-step': [], + }) + }) + + it('replaces a pending message by identity across both lists', async () => { + const { ctx, agent } = await inboxAgent('replace-inbox') + const inserted: UserMessage[] = [] + const discarded: UserMessage[] = [] + ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message)) + ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message)) + const original = createUserMessage({ + content: [{ type: 'text', text: 'original' }], + source: { kind: 'user' }, + }) + const nextStep = createUserMessage({ + content: [{ type: 'text', text: 'step' }], + source: { kind: 'user' }, + }) + const replacement = createUserMessage({ + content: [{ type: 'text', text: 'replacement' }], + source: { kind: 'user' }, + }) + const editedStep = freezeMessage({ + ...nextStep, + content: [{ type: 'text', text: 'edited step' }], + }) + agent.inbox.append('next-turn', original) + agent.inbox.append('next-step', nextStep) + + expect(agent.inbox.replace(createUserMessage({ + content: [{ type: 'text', text: 'missing' }], + source: { kind: 'user' }, + }).id, replacement)).toBe(false) + expect(agent.inbox.replace(original.id, replacement)).toBe(true) + expect(agent.inbox.replace(nextStep.id, editedStep)).toBe(true) + expect(agent.inbox.nextTurn).toEqual([replacement]) + expect(agent.inbox.nextStep).toEqual([editedStep]) + expect(discarded).toEqual([original, nextStep]) + expect(inserted).toEqual([original, nextStep, replacement, editedStep]) + expect(() => { agent.inbox.replace(editedStep.id, replacement) }) + .toThrow(`message "${replacement.id}" is already pending`) + }) + + it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => { + const { agent } = await inboxAgent('splice-inbox') + const first = createUserMessage({ + content: [{ type: 'text', text: 'first' }], + source: { kind: 'user' }, + }) + const second = createUserMessage({ + content: [{ type: 'text', text: 'second' }], + source: { kind: 'user' }, + }) + const prefixed = createUserMessage({ + content: [{ type: 'text', text: 'prefixed' }], + source: { kind: 'user' }, + }) + + agent.inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second]) + expect(agent.inbox.nextTurn).toEqual([first, second]) + expect(agent.inbox.splice('next-turn', -1, 1, [])).toEqual([second]) + agent.inbox.prepend('next-turn', prefixed) + expect(agent.inbox.nextTurn).toEqual([prefixed, first]) + expect(agent.inbox.remove(second.id)).toBe(false) + expect(() => { agent.inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`) + }) + + it('clears both pending lists as durable cancellations', async () => { + const { ctx, session, agent } = await inboxAgent('clear-inbox') + const discarded: UserMessage[] = [] + ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message)) + const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } }) + const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) + agent.inbox.append('next-turn', nextTurn) + agent.inbox.append('next-step', nextStep) + const beforeClear = session.snapshotEvents().length + + agent.inbox.clear() + + expect(agent.inbox.nextTurn).toEqual([]) + expect(agent.inbox.nextStep).toEqual([]) + expect(discarded).toEqual([nextStep, nextTurn]) + expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' + ? event.data + : event.type)).toEqual([ + { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, + { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, + ]) + + agent.inbox.clear() + expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) + }) +}) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 8ef5929522..b50fb45942 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -292,7 +292,8 @@ describe('agent/pre-step', () => { decision.resolve({ kind: 'enter', messages: claimed }) await idle - expect(agent.inbox.hasPending).toBe(false) + expect(agent.inbox.nextTurn).toEqual([]) + expect(agent.inbox.nextStep).toEqual([]) const staged = events(agent).filter(event => event.type === 'turn/start' || event.type === 'user/message') @@ -472,7 +473,8 @@ describe('agent/pre-step', () => { send(agent, 'blocked prompt') }).toThrow('append unavailable') expect(events(agent)).toEqual([]) - expect(agent.inbox.hasPending).toBe(false) + expect(agent.inbox.nextTurn).toEqual([]) + expect(agent.inbox.nextStep).toEqual([]) expect(agent.status).toBe('idle') }) diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index cfb2c64da2..c86e88cb65 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -8,7 +8,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm' import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as values from '@deepseek-ai/dsh-util-values' import { ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -23,14 +22,13 @@ afterEach(async () => { } }) -async function harness(adapter?: MockAdapter): Promise { +async function harness(adapter?: MockAdapter): Promise<{ ctx: Context; loopCtx: Context }> { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentLoop, { agents: [] }) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) if (adapter) ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter)) - return ctx + return { ctx, loopCtx: loopFiber.ctx } } async function send(agent: Agent, text: string): Promise { @@ -46,7 +44,7 @@ function expectFrozen(value: unknown): void { describe('loop-owned request freezing', () => { it('adopts restored identities, freezes nested messages at dispatch, and leaves event wrappers mutable', async () => { - const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')])) + const { ctx, loopCtx } = await harness(new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three'), textResponse('four')])) const id = SessionId('restored-freeze') const seed = Session.create(id) seed.append('user/message', createUserMessage({ @@ -75,7 +73,7 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(userEvent.data.content)).toBe(false) expect(Object.isFrozen(assistantEvent.data.message)).toBe(false) ctx.effect(() => ctx.sessions.enter(session)) - const agent = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session) + const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) cleanups.push(async () => { agent.cancel({ kind: 'disposed' }) await agent.whenIdle() @@ -124,7 +122,7 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(session.deriveMessages())).toBe(false) expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1) expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1) - const resumed = new ReactLoopAgent(ctx, id, { provider: 'mock', model: 'mock' }, session) + const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) cleanups.push(async () => { resumed.cancel({ kind: 'disposed' }) await resumed.whenIdle() @@ -136,7 +134,7 @@ describe('loop-owned request freezing', () => { }) it('retries freezing an identity whose previous traversal failed', async () => { - const ctx = await harness(new MockAdapter([textResponse('done')])) + const { ctx } = await harness(new MockAdapter([textResponse('done')])) const agent = await ctx.agentLoop.create(SessionId('freeze-failure'), { provider: 'mock', model: 'mock' }) const message = agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'history' }], source: { kind: 'user' }, @@ -163,7 +161,7 @@ describe('loop-owned request freezing', () => { it.each([true, false])('freezes each local header with an adapter present: %s', async (registered) => { const adapter = registered ? new MockAdapter([textResponse('one'), textResponse('two')]) : undefined - const ctx = await harness(adapter) + const { ctx } = await harness(adapter) const schemas: ToolSchema[][] = [] const stops: string[][] = [] ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { @@ -206,7 +204,7 @@ describe('loop-owned request freezing', () => { }) it('keeps the live request signal mutable and observes cancellation after dispatch', async () => { - const ctx = await harness(new MockAdapter(['hang'])) + const { ctx } = await harness(new MockAdapter(['hang'])) const agent = await ctx.agentLoop.create(SessionId('cancel-freeze'), { provider: 'mock', model: 'mock' }) const started = Promise.withResolvers() ctx.on('llm/stream', (request, next) => { started.resolve(request); return next() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index c27d0d2534..5f19bbc422 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -195,6 +195,33 @@ describe('agent scope lifecycle', () => { expect(after.sections.find(s => s.name === 'deployment:persona-prefix')?.text).toBe('You are the deployment.') }) + it('keeps the inbox projection until the last owning agent fiber unloads', async () => { + const ctx = await harness() + let first!: Awaited> + let second!: Awaited> + const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => { + first = await inner.agents.create({ + sessionId: SessionId('projection-owner-first'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + }, { inject: ['agents'] })) + const secondOwner = await ctx.plugin(Object.assign(async (inner: Context) => { + second = await inner.agents.create({ + sessionId: SessionId('projection-owner-second'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + }, { inject: ['agents'] })) + + expect(ctx.sessionProjections.stateOf(first.agent.session, 'inbox')).toBeDefined() + await firstOwner.dispose() + expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + await secondOwner.dispose() + expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined() + + await Promise.all([first.dispose(), second.dispose()]) + await ctx.fiber.dispose() + }) + it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) const a = await ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 7c0fb5c510..5180dec6b1 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: f413abd855be17fde2e38997df8269d1ca2670b5 -README.zh.md: 74797894177994cb3098aa67e969f45ced357634 +README.md: b1333e96c1eaf83c4a5598a23bdae068e227c25f +README.zh.md: d15ce22b8f845e268fb935382ff16c7fd20b9c67 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f413abd855..b1333e96c1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -84,14 +84,19 @@ The package is built on one separation: the public `Agent` surface and registry `PreStepDecision` is either `{ kind: 'reject' }` or `{ kind: 'enter', messages, startsRequestSeries? }`. The enter branch contains the complete identified, frozen message batch. `startsRequestSeries: true` declares a distinct model-message series; a wrapping listener preserves that declaration and the batch unless it intentionally replaces either one. Claiming removes offered messages from the inbox, while messages inserted after the claim remain pending for a later boundary. +### Durable inbox + +`Agent.inbox` exposes only the structural `Inbox` interface and the projection vocabulary stays in this package. dsh-agent-loop owns the package-internal `ReactLoopInbox` and the standard `inbox` projection; constructing its concrete inbox ensures that the projection registry owns one registration for the durable `agent/inbox/spliced` fold. The registry remains the sole owner of the live `{ 'next-turn', 'next-step' }` state. Reconstruction rejects unsafe or out-of-range splice coordinates and duplicate `MessageId` values across both pending lists and reports the offending event seq. + +`Inbox` exposes pending `nextTurn` and `nextStep` messages and mutates them through `append`, `prepend`, `replace`, `remove`, `clear`, and `splice`. Ordinary removals and `clear()` are durable cancellations. At a step boundary, the loop's internal implementation claims pending input through pure deletion splices. Live notifications are deliberately per-message and minimal: `agent/inbox/inserted { message }`, `agent/inbox/claimed { message, turn }`, and `agent/inbox/discarded { message }`. + ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentRegistry`, factory slot, initiator scope, `CreateAgentOptions`/`ResumeAgentOptions` | -| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, `AgentStatus`, and the `agent/*` event declarations | -| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox vocabulary | -| [`src/inbox.ts`](src/inbox.ts) | The `Inbox` projection over durable `agent/inbox/spliced` events | +| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`, structural `Inbox`, `AgentStatus`, and the `agent/*` event declarations | +| [`src/types.ts`](src/types.ts) | `AgentOptions`, cancellation causes, and inbox projection vocabulary | | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` fused dispatcher and `assembleContextFor(agent)` | | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`: what the log's consumed work became | | [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`: coupling one selection to assembly and routing | diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 7479789417..d15ce22b8f 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -84,14 +84,19 @@ await handle.agent.whenIdle() `PreStepDecision` 要么是 `{ kind: 'reject' }`,要么是 `{ kind: 'enter', messages, startsRequestSeries? }`。enter 分支包含完整、带标识且冻结的消息批次。`startsRequestSeries: true` 声明一个独立的模型消息序列;包装下游 enter 的监听器会保留该声明与批次,除非有意替换其中一项。领取会从 inbox 移除候选消息,领取后插入的消息则等待后续边界。 +### 持久 inbox + +`Agent.inbox` 只暴露结构化 `Inbox` 接口,投影词汇仍位于本包。dsh-agent-loop 持有包内部的 `ReactLoopInbox` 与标准 `inbox` 投影;构造具体 inbox 时会确保投影注册表为持久 `agent/inbox/spliced` fold 持有一份注册。注册表继续作为实时 `{ 'next-turn', 'next-step' }` 状态的唯一所有者。重建过程会拒绝不安全或越界的 splice 坐标,以及跨两份待处理列表重复的 `MessageId`,并报告出错事件的 seq。 + +`Inbox` 暴露待处理的 `nextTurn` 与 `nextStep` 消息,并通过 `append`、`prepend`、`replace`、`remove`、`clear` 与 `splice` 变更它们。普通删除和 `clear()` 都是持久取消。在步骤边界,循环的内部实现会通过纯删除 splice 领取待处理输入。实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserted { message }`、`agent/inbox/claimed { message, turn }` 与 `agent/inbox/discarded { message }`。 + ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`AgentRegistry`、工厂槽位、发起方作用域、`CreateAgentOptions`/`ResumeAgentOptions` | -| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`、`AgentStatus` 与 `agent/*` 事件声明 | -| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱词汇 | -| [`src/inbox.ts`](src/inbox.ts) | 持久 `agent/inbox/spliced` 事件之上的 `Inbox` 投影 | +| [`src/runtime-types.ts`](src/runtime-types.ts) | `Agent`、结构化 `Inbox`、`AgentStatus` 与 `agent/*` 事件声明 | +| [`src/types.ts`](src/types.ts) | `AgentOptions`、取消原因与收件箱投影词汇 | | [`src/dispatch.ts`](src/dispatch.ts) | `agentEvents` 融合分发器与 `assembleContextFor(agent)` | | [`src/consumed-work.ts`](src/consumed-work.ts) | `foldConsumedWork(events)`:日志消费掉的工作最终怎样了 | | [`src/model-selection.ts`](src/model-selection.ts) | `installModelSelection`:把一个选择耦合到组装与路由 | diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 23d5d34088..11fa110fcf 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -41,20 +41,22 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts deleted file mode 100644 index c5d50b222a..0000000000 --- a/packages/core/agent/src/inbox.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Incremental projection of durable agent inbox events. - * - * @module @deepseek-ai/dsh-agent/inbox - */ - -import type { MessageId } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' -import type { InboxTarget } from './types.ts' - -/** Mutable state privately owned by an {@link Inbox}. */ -type InboxState = Record - -/** Live notifications committed by inbox mutations. */ -export interface InboxNotifications { - /** Publish one inserted message. */ - inserted(message: UserMessage): void - /** Publish one discarded message. */ - discarded(message: UserMessage): void - /** Publish one claimed message inside its owning turn. */ - claimed(message: UserMessage, turn: number): void -} - -/** A replay-once projection that incrementally consumes later inbox splices. */ -export class Inbox { - private readonly state: InboxState = { 'next-turn': [], 'next-step': [] } - - constructor( - private readonly session: Session, - private readonly notifications: InboxNotifications, - ) { - for (const event of session.ownEvents()) { - if (event.type !== 'agent/inbox/spliced') continue - try { - this.apply(event.data) - } catch (error: unknown) { - throw new Error(`invalid persisted inbox splice at session seq ${event.seq}`, { cause: error }) - } - } - } - - /** Prompts awaiting individual turns. */ - get nextTurn(): readonly UserMessage[] { - return this.state['next-turn'] - } - - /** Input awaiting the next step boundary. */ - get nextStep(): readonly UserMessage[] { - return this.state['next-step'] - } - - /** Whether either pending-message list contains work. */ - get hasPending(): boolean { - return this.nextTurn.length > 0 || this.nextStep.length > 0 - } - - /** Durably cancel all pending input, clearing next-step before next-turn. */ - clear(): void { - this.splice('next-step', 0, this.nextStep.length, []) - this.splice('next-turn', 0, this.nextTurn.length, []) - } - - /** - * Remove and return the complete batch proposed for one step, publishing - * each claimed message. The durable splices are pure deletions. - * @param target - whether this boundary also consumes one queued turn. - * @param turn - turn that will own the claimed batch. - * @returns next-step input followed by the queued turn, when requested. - * @internal - The agent loop's step-boundary operation, not a plugin extension point. - */ - claim(target: InboxTarget, turn: number): UserMessage[] { - const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) - if (target === 'next-turn') { - claimed.push(...this.mutate('next-turn', 0, 1, [], false)) - } - for (const message of claimed) this.notifications.claimed(message, turn) - return claimed - } - - /** - * Append one message to a pending list and durably record the insertion. - * @param target - pending list to extend. - * @param message - message to append. - * @throws if the message identity is already pending. - */ - append(target: InboxTarget, message: UserMessage): void { - this.splice(target, this.state[target].length, 0, [message]) - } - - /** - * Prepend one message to a pending list and durably record the insertion. - * @param target - pending list to extend. - * @param message - message to prepend. - * @throws if the message identity is already pending. - */ - prepend(target: InboxTarget, message: UserMessage): void { - this.splice(target, 0, 0, [message]) - } - - /** - * Replace one pending message in place, possibly changing its identity. A - * successful replacement publishes the old message as discarded and the new - * message as inserted. - * @param messageId - identity of the pending message to replace. - * @param newMessage - replacement message. - * @returns whether the message was still pending. - * @throws if the replacement duplicates another pending message identity. - */ - replace(messageId: MessageId, newMessage: UserMessage): boolean { - const location = this.locate(messageId) - if (location === undefined) return false - this.splice(location.target, location.index, 1, [newMessage]) - return true - } - - /** - * Remove one pending message and durably record its cancellation. - * @param messageId - identity of the pending message to remove. - * @returns whether the message was still pending. - */ - remove(messageId: MessageId): boolean { - const location = this.locate(messageId) - if (location === undefined) return false - this.splice(location.target, location.index, 1, []) - return true - } - - /** - * Apply standard splice semantics and durably record the normalized result. - * The durable event commits before the live projection mutates, so synchronous - * `session/event` observers see the pre-splice lists and can reconstruct the - * removed messages from the normalized coordinates. - * @param target - pending list to mutate. - * @param start - splice position. - * @param deleteCount - maximum number of messages to remove. - * @param inserted - messages to insert at the resolved position. - * @returns messages removed by the splice. - */ - splice( - target: InboxTarget, - start: number, - deleteCount: number, - inserted: UserMessage[], - ): UserMessage[] { - return this.mutate(target, start, deleteCount, inserted, true) - } - - /** Locate one pending identity across both owned lists. */ - private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { - for (const target of ['next-turn', 'next-step'] as const) { - const index = this.state[target].findIndex(message => message.id === messageId) - if (index >= 0) return { target, index } - } - return undefined - } - - /** Commit one normalized mutation and publish its live notifications. */ - private mutate( - target: InboxTarget, - start: number, - deleteCount: number, - inserted: UserMessage[], - discardRemoved: boolean, - ): UserMessage[] { - const inbox = this.state[target] - const truncatedStart = Math.trunc(start) - const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart - const actualStart = offset < 0 - ? Math.max(inbox.length + offset, 0) - : Math.min(offset, inbox.length) - const truncatedDeleteCount = Math.trunc(deleteCount) - const actualDeleteCount = Math.min( - Math.max(Number.isNaN(truncatedDeleteCount) ? 0 : truncatedDeleteCount, 0), - inbox.length - actualStart, - ) - if (actualDeleteCount === 0 && inserted.length === 0) return [] - const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined - const splice = { - target, - start: actualStart, - ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }), - inserted, - ...(outcome === undefined ? {} : { outcome }), - } - this.validate(splice) - const event = this.session.append('agent/inbox/spliced', splice) - const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted) - if (discardRemoved) { - for (const message of removed) this.notifications.discarded(message) - } - for (const message of event.data.inserted) this.notifications.inserted(message) - return removed - } - - /** Apply one normalized durable splice to the projection. */ - private apply(splice: SessionEventMap['agent/inbox/spliced']): UserMessage[] { - this.validate(splice) - const inbox = this.state[splice.target] - return inbox.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) - } - - /** Validate one normalized splice against the current projection. */ - private validate(splice: SessionEventMap['agent/inbox/spliced']): void { - const inbox = this.state[splice.target] - const removedCount = splice.removedCount ?? 0 - if (!Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > inbox.length - || !Number.isSafeInteger(removedCount) || removedCount < 0 - || splice.start + removedCount > inbox.length) { - throw new Error('invalid inbox splice') - } - const candidate = inbox.toSpliced(splice.start, removedCount, ...splice.inserted) - const ids = new Set() - for (const message of splice.target === 'next-turn' - ? [...candidate, ...this.nextStep] - : [...this.nextTurn, ...candidate]) { - if (ids.has(message.id)) throw new Error(`message "${message.id}" is already pending`) - ids.add(message.id) - } - } -} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 7df60db27c..e9d41bc0dd 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -18,7 +18,6 @@ import type { AgentOptions } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' export type * from './projection.ts' -export * from './inbox.ts' export * from './consumed-work.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 2b6931b257..8f2f471f12 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -8,12 +8,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { - LlmAttemptId, LlmCallConfig, LlmFailure, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk, + LlmAttemptId, LlmCallConfig, LlmFailure, MessageId, ReasoningEffortId, ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session' export type { AgentCancelCause } from '@deepseek-ai/dsh-session' -import type { Inbox } from './inbox.ts' -import type { Agent } from './types.ts' +import type { Agent, InboxTarget } from './types.ts' export type { Agent } from './types.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { @@ -45,6 +44,61 @@ export interface CancelOptions { keepInbox?: boolean | undefined } +/** Agent-owned access to pending work; concrete storage belongs to the driver. */ +export interface Inbox { + /** Prompts awaiting individual turns. */ + readonly nextTurn: readonly UserMessage[] + /** Input awaiting the next step boundary. */ + readonly nextStep: readonly UserMessage[] + + /** Durably cancel all pending input, clearing next-step before next-turn. */ + clear(): void + + /** + * Append one message to a pending list. + * @param target - pending list to extend. + * @param message - message to append. + */ + append(target: InboxTarget, message: UserMessage): void + + /** + * Prepend one message to a pending list. + * @param target - pending list to extend. + * @param message - message to prepend. + */ + prepend(target: InboxTarget, message: UserMessage): void + + /** + * Replace one pending message in place. + * @param messageId - identity of the pending message to replace. + * @param newMessage - replacement message. + * @returns whether the message was still pending. + */ + replace(messageId: MessageId, newMessage: UserMessage): boolean + + /** + * Remove one pending message. + * @param messageId - identity of the pending message to remove. + * @returns whether the message was still pending. + */ + remove(messageId: MessageId): boolean + + /** + * Apply standard splice semantics and durably record the normalized result. + * @param target - pending list to mutate. + * @param start - splice position. + * @param deleteCount - maximum number of messages to remove. + * @param inserted - messages to insert at the resolved position. + * @returns messages removed by the splice. + */ + splice( + target: InboxTarget, + start: number, + deleteCount: number, + inserted: UserMessage[], + ): UserMessage[] +} + /** * An agent's lifecycle state, emitted on every transition as `agent/status`: * `idle` means no driver is active; `running` begins when waking input starts @@ -112,7 +166,7 @@ declare module './types.ts' { readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ readonly session: Session - /** The agent-owned projection of durable pending work. */ + /** Agent-owned access to durable pending work. */ readonly inbox: Inbox /** The current lifecycle state, mirrored on every `agent/status` transition. */ readonly status: AgentStatus diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index c7b82e243e..d0be69ac58 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -7,6 +7,7 @@ import type { UserMessage } from '@deepseek-ai/dsh-llm/types' import type { OptionalSessionSeq, SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' /** Public live-agent handle; the runtime face augments its live capabilities. */ export interface Agent { @@ -28,6 +29,34 @@ declare module '@deepseek-ai/dsh-typert-protocol' { /** One of the two ordered pending-message lists owned by an agent. */ export type InboxTarget = 'next-turn' | 'next-step' +/** Complete pending Inbox value reconstructed from durable splices. */ +export interface InboxState { + readonly 'next-turn': readonly UserMessage[] + readonly 'next-step': readonly UserMessage[] +} + +/** + * Wire-JSON pending Inbox value. Each message round-trips the session log + * losslessly, but the fold state's full `UserMessage` type cannot cross a + * typert Remote boundary (its source union carries an `unknown` replay + * field), so the typed projection table keeps this JSON-safe form. + */ +export interface InboxWireState { + readonly 'next-turn': readonly JsonValue[] + readonly 'next-step': readonly JsonValue[] +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Pending agent input reconstructed from durable inbox splices. */ + inbox: InboxState + } + interface SessionProjectionMap { + /** Pending agent input reconstructed from durable inbox splices. */ + inbox: InboxWireState + } +} + /** * Turn and step boundaries folded from one agent session log. * @@ -52,8 +81,8 @@ declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * One normalized mutation of an agent's durable pending-message lists. - * Live dispatch precedes projection mutation, so synchronous observers may - * read the pre-splice inbox to recover the removed messages. + * The session-projection registry applies the committed event before + * `Session.append()` returns; Inbox live notifications follow that commit. */ 'agent/inbox/spliced': { target: InboxTarget diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index e1da38b652..f0649dd5f5 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,11 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' -import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' -import AgentRegistry, { - agentEvents, - Inbox, -} from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { @@ -19,14 +15,17 @@ import type { function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) - const session = Session.create(id) + const session = overrides.session ?? Session.create(id) + const ctx = overrides.ctx ?? new Context() const agent: Agent = { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: { + nextTurn: [], nextStep: [], + } as never, status: 'idle', - ctx: new Context(), + ctx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -34,114 +33,11 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), + ...overrides, } - return Object.assign(agent, overrides) + return agent } -describe('Inbox', () => { - it('rejects an invalid durable splice during reconstruction', () => { - const session = Session.create(SessionId('invalid-inbox-replay')) - session.append('agent/inbox/spliced', { - target: 'next-turn', - start: 1, - inserted: [], - }) - - expect(() => new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })) - .toThrow('invalid persisted inbox splice at session seq 0') - }) - - it('replaces a pending message by identity across both lists', () => { - const session = Session.create(SessionId('replace-inbox')) - const inserted: UserMessage[] = [] - const discarded: UserMessage[] = [] - const inbox = new Inbox(session, { - claimed: () => {}, - inserted: message => void inserted.push(message), - discarded: message => void discarded.push(message), - }) - const original = createUserMessage({ - content: [{ type: 'text', text: 'original' }], - source: { kind: 'user' }, - }) - const nextStep = createUserMessage({ - content: [{ type: 'text', text: 'step' }], - source: { kind: 'user' }, - }) - const replacement = createUserMessage({ - content: [{ type: 'text', text: 'replacement' }], - source: { kind: 'user' }, - }) - const editedStep = freezeMessage({ - ...nextStep, - content: [{ type: 'text', text: 'edited step' }], - }) - inbox.append('next-turn', original) - inbox.append('next-step', nextStep) - - expect(inbox.replace(createUserMessage({ - content: [{ type: 'text', text: 'missing' }], - source: { kind: 'user' }, - }).id, replacement)).toBe(false) - expect(inbox.replace(original.id, replacement)).toBe(true) - expect(inbox.replace(nextStep.id, editedStep)).toBe(true) - expect(inbox.nextTurn).toEqual([replacement]) - expect(inbox.nextStep).toEqual([editedStep]) - expect(discarded).toEqual([original, nextStep]) - expect(inserted).toEqual([original, nextStep, replacement, editedStep]) - expect(() => { inbox.replace(editedStep.id, replacement) }) - .toThrow(`message "${replacement.id}" is already pending`) - }) - - it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => { - const session = Session.create(SessionId('splice-inbox')) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - const first = createUserMessage({ - content: [{ type: 'text', text: 'first' }], - source: { kind: 'user' }, - }) - const second = createUserMessage({ - content: [{ type: 'text', text: 'second' }], - source: { kind: 'user' }, - }) - - inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second]) - expect(inbox.nextTurn).toEqual([first, second]) - expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second]) - expect(inbox.remove(second.id)).toBe(false) - expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`) - }) - - it('clears both pending lists as durable cancellations', () => { - const session = Session.create(SessionId('clear-inbox')) - const discarded: UserMessage[] = [] - const inbox = new Inbox(session, { - claimed: () => {}, - inserted: () => {}, - discarded: message => void discarded.push(message), - }) - const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } }) - const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) - inbox.append('next-turn', nextTurn) - inbox.append('next-step', nextStep) - const beforeClear = session.snapshotEvents().length - - inbox.clear() - - expect(inbox.hasPending).toBe(false) - expect(discarded).toEqual([nextStep, nextTurn]) - expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced' - ? event.data - : event.type)).toEqual([ - { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, - { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }, - ]) - - inbox.clear() - expect(session.snapshotEvents()).toHaveLength(beforeClear + 2) - }) -}) - describe('AgentRegistry', () => { it('contributes Agent lookup and scoped Context providers while Typert is live', async () => { const ctx = new Context() diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index f7aae0283e..068c1e6b8f 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -23,15 +23,15 @@ { "path": "../../core/session" }, + { + "path": "../../session/session-projection" + }, { "path": "../../core/system-prompt" }, { "path": "../../runtime-diagnostics/invariants" }, - { - "path": "../../session/session-projection" - }, { "path": "../../typert/protocol" } diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 88fa83c233..8c0b15f0bf 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-fs-e2b": "workspace:^", diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index dbbdff065e..de6f192bf3 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -3,7 +3,6 @@ import { join, posix } from 'node:path' import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { @@ -17,6 +16,7 @@ import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { Session, SessionId } from '@deepseek-ai/dsh-session' import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const fixtureRoot = fileURLToPath(new URL('./fixtures/composition/', import.meta.url)) const binScript = join(fixtureRoot, 'bin.ts') @@ -87,7 +87,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { id: ownerId, options: {}, session: ownerSession, - inbox: new Inbox(ownerSession, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx, send() {}, diff --git a/packages/e2b/e2b/tests/fixtures/composition/bin.ts b/packages/e2b/e2b/tests/fixtures/composition/bin.ts index 4e4ffc120e..d762a2dda7 100644 --- a/packages/e2b/e2b/tests/fixtures/composition/bin.ts +++ b/packages/e2b/e2b/tests/fixtures/composition/bin.ts @@ -1,8 +1,7 @@ import { readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { boot } from '@deepseek-ai/dsh-app-boot' -import { Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-fs-e2b' import type {} from '@deepseek-ai/dsh-bash-local' @@ -16,11 +15,23 @@ const ctx = await boot('e2b-composition', resolve(configPath)) const ownerFiber = ctx.plugin(() => {}) const ownerId = SessionId('e2b-live-owner') const session = Session.create(ownerId) +const unsupportedInboxMutation = (): never => { + throw new Error('the E2B composition owner does not support Inbox mutations') +} const owner: Agent = { id: ownerId, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: { + nextTurn: [], + nextStep: [], + clear: unsupportedInboxMutation, + append: unsupportedInboxMutation, + prepend: unsupportedInboxMutation, + replace: unsupportedInboxMutation, + remove: unsupportedInboxMutation, + splice: unsupportedInboxMutation, + }, status: 'idle', ctx: ownerFiber.ctx, send() {}, diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 7fc870e5db..6ff838ddf0 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -8,9 +8,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -102,7 +100,6 @@ async function stack( const ctx = new Context() contexts.add(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await backend.mount(ctx, root) await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 321192432f..c48190f421 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SubagentService from '@deepseek-ai/dsh-subagent' import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal' @@ -62,7 +61,6 @@ async function setup( ) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) @@ -170,7 +168,6 @@ describe('Team identity and provisioning', () => { it('supports direct-constructor defaults and recovers roots that already exist', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) @@ -1411,7 +1408,6 @@ describe('Team mailbox and waiting', () => { it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index dffad3b4d7..6ae2fddca3 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { ToolCallId } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -56,7 +55,6 @@ afterEach(() => { async function setup(script: ConstructorParameters[0], legacyControl = false) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-')) roots.push(storageRoot) await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a1c333b265..d1eb6cf914 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -610,6 +610,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'id', description: 'entry id (package name).' }], returns: 'the path, or undefined for an unknown id.', }, + { + signature: 'fetchBundle(request: Request): Response', + description: 'Serve an advertised revisioned bundle or source map without a Web server. Unknown URLs return 404, unsupported methods return 405, and `HEAD` returns the same immutable headers without a body.', + parameters: [{ name: 'request', description: 'shell-carrier request for a `/plugins` resource.' }], + returns: 'the exact response also exposed by the optional Web route.', + }, { signature: 'artifactBaseline(id: string): ClientArtifactBaseline | undefined', description: 'Filesystem baseline captured before an entry\'s current bytes were read. HMR compares it with the live files when installing a watch, so a write between startup composition and watch installation cannot disappear into the watcher\'s initial state.', diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 7cbe0ee6cb..d93e2fdb5a 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 71aa288cc5..e59020bfc6 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => { const USER_ID = '01234567-89ab-4cde-8f01-23456789abcd' @@ -28,13 +29,12 @@ interface Harness { /** Build a live idle agent over a store-owned session, as an app's spine does. */ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { const session = ctx.sessions.create(SessionId(id)) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) let status: AgentStatus = 'idle' const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: unsupportedInbox(), ctx: new Context(), get status() { return status }, send: () => {}, diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 1ad8abf770..6ab826e516 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -6,12 +6,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import * as CommandFeedback from '@deepseek-ai/dsh-command-feedback' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' let root: string | undefined let context: Context | undefined @@ -29,13 +30,12 @@ function agent(ctx: Context): Agent { const scope = ctx.plugin(() => {}) const id = SessionId('feedback-loader-agent') const session = ctx.sessions.create(id) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) let status: AgentStatus = 'idle' const value: Agent = { id, options: {}, session, - inbox, + inbox: unsupportedInbox(), ctx: scope.ctx, get status() { return status }, send: () => {}, diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 184e74f099..b556ec9270 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -1,7 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' @@ -15,7 +14,6 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' */ export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: persona } }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 8cfa3da728..f2e438074b 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 7d32b26d72..e50f87f3a3 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis' import { FsVersion } from '@deepseek-ai/dsh-fs' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' @@ -16,6 +16,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const contexts: Context[] = [] const roots: string[] = [] @@ -36,7 +37,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 28b99bf4b2..8f4439dbb8 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -35,6 +35,8 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index ed53189f7d..1c1761cf74 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandRuntime from '@deepseek-ai/dsh-commands' import GoalService from '@deepseek-ai/dsh-goal' @@ -9,6 +9,7 @@ import type { GoalRef } from '@deepseek-ai/dsh-goal' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as commandGoal from '@deepseek-ai/dsh-command-goal' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' interface Harness { readonly ctx: Context @@ -21,7 +22,7 @@ interface Harness { function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { // Store-created: the command executor durably logs lifecycle events on it. const session = ctx.sessions.create(SessionId(id)) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = createInboxStub() let status: AgentStatus = 'idle' const agent: Agent = { id: session.id, @@ -33,7 +34,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } send: () => {}, followup: () => {}, steer: () => {}, - inject(input) { inbox.append('next-step', input) }, + inject(input) { this.inbox.append('next-step', input) }, cancel() { status = 'idle' }, runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, @@ -45,9 +46,9 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } async function harness(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(CommandRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const plugin = await ctx.plugin(commandGoal) const { agent, session } = stubAgent(ctx, `command-goal-${Math.random()}`) diff --git a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts index 5cef3e75de..2ec805d71b 100644 --- a/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts +++ b/packages/goal/goal-round-driver/tests/goal-round-driver.spec.ts @@ -10,7 +10,6 @@ import { createUserMessage, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { UserMessage } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as goalSession from '../src/index.ts' type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[]) @@ -90,7 +89,6 @@ async function harness(script: ScriptEntry[]): Promise { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) const driver = await ctx.plugin(goalSession) await ctx.plugin(AgentLoop, { agents: [] }) @@ -217,7 +215,6 @@ describe('same-session goal driving', () => { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(GoalService) await ctx.plugin(AgentLoop, { agents: [] }) const adapter = new ScriptedAdapter([textResponse('after resume')]) diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 878fa2f6dc..6db9f2da2b 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -69,6 +69,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index d72ac7c751..e3140e9231 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' @@ -12,12 +12,19 @@ import GoalService, { foldGoal, } from '@deepseek-ai/dsh-goal' import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' interface StubAgent { agent: Agent session: Session } +const isolatedInboxCtx = new Context() +await isolatedInboxCtx.plugin(SessionStore) +await isolatedInboxCtx.plugin(SessionProjectionRegistry) +await isolatedInboxCtx.plugin(AgentRegistry) +const sessionStubs = new WeakMap() + /** Number the next balanced test-fixture turn. */ function nextTurn(session: Session): number { return session.snapshotEvents().reduce((max, event) => event.type === 'turn/start' ? Math.max(max, event.data.turn) : max, 0) + 1 @@ -25,46 +32,61 @@ function nextTurn(session: Session): number { /** Mirror the public Agent.inject contract for domain tests. */ function appendInjection(session: Session, input: UserMessage): void { - new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }).append('next-step', input) + stubAgentForSession(session).agent.inbox.append('next-step', input) } /** Build a registry-compatible agent around one concrete session. */ -function stubAgentForSession(session: Session): StubAgent { +function stubAgentForSession(session: Session, suppliedCtx?: Context): StubAgent { + const existing = sessionStubs.get(session) + if (existing !== undefined) return existing const id = session.id - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const agentCtx = suppliedCtx ?? isolatedInboxCtx + if (suppliedCtx === undefined) { + agentCtx.sessions.enter(session) + } + const inbox = createInboxStub() const agent: Agent = { id, options: {}, session, inbox, - ctx: new Context(), + ctx: agentCtx, status: 'idle', send: () => {}, followup: () => {}, steer: () => {}, - inject(input) { inbox.append('next-step', input) }, + inject(input) { this.inbox.append('next-step', input) }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - return { + const stub = { agent, session, } + sessionStubs.set(session, stub) + return stub } /** Build a registry-compatible agent around a fresh session. */ -function stubAgent(rawId: string, seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[]): StubAgent { - return stubAgentForSession(Session.create(SessionId(rawId), seed)) +function stubAgent( + rawId: string, + seed?: readonly import('@deepseek-ai/dsh-session').SessionEvent[], + ctx?: Context, +): StubAgent { + const session = ctx === undefined + ? Session.create(SessionId(rawId), seed) + : ctx.sessions.create(SessionId(rawId), { ...(seed === undefined ? {} : { seed }) }) + return stubAgentForSession(session, ctx) } async function harness(config: { defaultMaxGoalRounds?: number } = {}) { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService, config) - const stub = stubAgentForSession(ctx.sessions.create(SessionId(`goal-test-${Math.random()}`))) + const stub = stubAgent(`goal-test-${Math.random()}`, undefined, ctx) ctx.agents.register(stub.agent) return { ctx, ...stub } } @@ -182,15 +204,15 @@ describe('GoalService creation and replay', () => { it('inherits the completed-turn goal prefix through SessionStore.fork with child activation disarmed', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) - const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent'))) + const parent = stubAgentForSession(ctx.sessions.create(SessionId('goal-fork-parent')), ctx) ctx.agents.register(parent.agent) const goal = ctx.goals.create(parent.agent, { objective: 'inherit through fork', maxGoalRounds: 5 }) appendRound(parent.session, goal, 1) - const child = stubAgentForSession(ctx.sessions.fork(parent.session)) + const child = stubAgentForSession(ctx.sessions.fork(parent.session), ctx) ctx.agents.register(child.agent) expect(ctx.goals.get(child.agent)).toMatchObject({ id: goal.id, @@ -252,7 +274,7 @@ describe('GoalService creation and replay', () => { const { ctx, agent } = await harness() // A same-id agent backed by a different session object — the live-instance // check must reject it even though the ids match. - const impostor = stubAgentForSession(Session.create(agent.id)).agent + const impostor = { ...agent, session: Session.create(agent.id) } as Agent expect(() => ctx.goals.get(impostor)).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE' })) expect(() => ctx.goals.create(impostor, { objective: 'no' })).toThrow(expect.objectContaining({ code: 'GOAL_AGENT_NOT_LIVE', @@ -439,10 +461,10 @@ describe('GoalService mutations', () => { it('publishes a mutation consistently to a reentrant session observer', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) await ctx.plugin(GoalService) - const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer'))) + const stub = stubAgentForSession(ctx.sessions.create(SessionId('goal-reentrant-observer')), ctx) ctx.agents.register(stub.agent) let observed: ReturnType ctx.on('session/event', (session, event) => { @@ -501,6 +523,31 @@ describe('GoalService mutations', () => { }) }) + it('rejects a corrupt append while preserving the valid prefix', async () => { + const { ctx, agent, session } = await harness() + expect(ctx.goals.get(agent)).toBeUndefined() + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: 1, + operation: 'create', + goal: { + id: GoalId('goal-valid-prefix'), + revision: 1, + objective: 'valid prefix', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 12, + updatedAt: 12, + } + session.append('goal/change', change) + expect(() => { + session.append('goal/change', { ...change, operation: 'edit', extra: true } as never) + }).toThrow('snapshot change must have exactly') + + expect(ctx.goals.get(agent)).toMatchObject({ id: change.goal.id, objective: 'valid prefix' }) + }) }) describe('goal replay validation', () => { @@ -566,7 +613,7 @@ describe('goal replay validation', () => { content: [{ type: 'text', text: 'unrelated pending context' }], source: { kind: 'plugin', plugin: 'test' }, }) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const inbox = stubAgentForSession(session).agent.inbox inbox.append('next-step', message) expect(inbox.remove(message.id)).toBe(true) expect(foldGoal(session.snapshotEvents())).toMatchObject({ goal: { id: change.goal.id, revision: 1 } }) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 4dd473db31..840141a1da 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -10,15 +10,15 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { UserMessage } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import GoalService, { GoalId, applyGoalProjection, foldGoal, goalProjectionDefinition } from '@deepseek-ai/dsh-goal' import type { GoalProjection, GoalProjectionState, GoalRef } from '@deepseek-ai/dsh-goal' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' interface Bench { ctx: Context @@ -31,20 +31,17 @@ interface Bench { /** Register a minimal registry-compatible live agent over a store session. */ function liveAgent(ctx: Context, session: Session): Agent { const status: AgentStatus = 'idle' - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: unsupportedInbox(), ctx, get status() { return status }, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), - inject(input: UserMessage) { - inbox.append('next-step', input) - }, + inject: () => { throw new Error('goal projection tests do not inject model context') }, cancel() {}, runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, @@ -82,7 +79,7 @@ describe('goal projection unit', () => { it('serves null before the first create', async () => { const bench = await harness(true) seedMessage(bench.session) - expect(bench.tailValues()).toEqual({ goal: null }) + expect(bench.tailValues().goal).toBeNull() expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1) }) @@ -130,10 +127,14 @@ describe('goal projection unit', () => { const created = bench.ctx.goals.create(bench.agent, { objective: 'stay cleared' }) bench.ctx.goals.clear(bench.agent, created) - bench.agent.inbox.prepend('next-step', createUserMessage({ - content: [{ type: 'text', text: 'unrelated pending context' }], - source: { kind: 'plugin', plugin: 'test' }, - })) + bench.session.append('agent/inbox/spliced', { + target: 'next-step', + start: 0, + inserted: [createUserMessage({ + content: [{ type: 'text', text: 'unrelated pending context' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) expect(bench.tailValues().goal).toBeNull() expect(foldGoal(bench.session.snapshotEvents()).goal).toBeUndefined() @@ -240,7 +241,7 @@ describe('goal projection unit', () => { const bench = await harness(false) seedMessage(bench.session) const fiber = await bench.ctx.plugin(GoalService) - expect(bench.tailValues()).toEqual({ goal: null }) + expect(bench.tailValues().goal).toBeNull() await fiber.dispose() expect('goal' in (bench.tailValues() ?? {})).toBe(false) }) diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index ec03183b8f..5ba86bc1ac 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -42,6 +42,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 1839a9ecdf..533b6aa6c6 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,44 +1,58 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent' import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' -import { +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' const testToolSignal = new AbortController().signal interface StubAgent { readonly agent: Agent readonly session: Session + readonly inbox: Inbox setStatus(status: AgentStatus): void } -/** Build one registry-compatible live agent whose injections enter the durable inbox. */ -function stubAgent(rawId: string, supplied?: Session): StubAgent { - const session = supplied ?? Session.create(SessionId(rawId)) +const isolatedInboxCtx = new Context() +await isolatedInboxCtx.plugin(SessionStore) +await isolatedInboxCtx.plugin(SessionProjectionRegistry) +await isolatedInboxCtx.plugin(AgentRegistry) + +/** Build one registry-compatible live agent whose injections enter its test Inbox. */ +function stubAgent(rawId: string, supplied?: Session, suppliedCtx?: Context): StubAgent { + const agentCtx = suppliedCtx ?? isolatedInboxCtx + const session = supplied ?? (suppliedCtx === undefined + ? agentCtx.sessions.create(SessionId(rawId)) + : suppliedCtx.sessions.create(SessionId(rawId))) + if (suppliedCtx === undefined) { + if (agentCtx.sessions.get(session.id) !== session) agentCtx.sessions.enter(session) + } + const inbox = createInboxStub() let status: AgentStatus = 'running' const agent: Agent = { id: session.id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox, get status() { return status }, - ctx: new Context(), + ctx: agentCtx, send: () => {}, followup: () => {}, steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), @@ -49,7 +63,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { runMaintenance: task => task(new AbortController().signal), whenIdle() { return Promise.resolve() }, } - return { agent, session, setStatus(value) { status = value } } + return { agent, session, inbox, setStatus(value) { status = value } } } /** Open one message-triggered turn with its accepted model-visible input. */ @@ -62,7 +76,7 @@ function openTurn(stub: StubAgent, source: MessageSource, text = 'prompt'): numb source, }) stub.agent.inbox.append('next-turn', message) - const claimed = stub.agent.inbox.claim('next-turn', turn) + const claimed = stub.inbox.splice('next-turn', 0, 1, []) if (claimed.length === 0) throw new Error('expected queued turn input') stub.session.append('turn/start', { turn }) for (const admitted of claimed) { @@ -78,14 +92,15 @@ function closeTurn(stub: StubAgent, turn: number): void { async function harness(config: toolGoal.Config = {}) { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) - await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(GoalService) const fiber = await ctx.plugin(toolGoal, config) - const root = stubAgent(`goal-tool-root-${Math.random()}`) + const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) ctx.agents.register(root.agent) return { ctx, fiber, root } } @@ -252,7 +267,7 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'user' }) // A distinct agent object over root's exact session: same id, not the live // registered instance, so the executor must reject it. - const stale = stubAgent('goal-tool-stale', root.agent.session).agent + const stale = stubAgent('goal-tool-stale', root.agent.session, ctx).agent const staleResult = await execute(ctx, 'get_goal', {}, stale, stale) expect(staleResult.error?.info?.code).toBe('GOAL_TOOL_DRIVER_REQUIRED') diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index b8a32f2370..4c505d6cf0 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -6,7 +6,6 @@ import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-reminder' import type { Config } from '@deepseek-ai/dsh-repeat-tool-reminder' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -25,8 +24,6 @@ const testToolSignal = new AbortController().signal async function harness(config: Config = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - // AgentLoop declares the registry as a required injection. - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(RepeatToolGuard, config) ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) @@ -373,7 +370,6 @@ describe('config validation fails loud', () => { async function spine(): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) return ctx } diff --git a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts index 7f5facfa65..68f6c8319d 100644 --- a/packages/hooks/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude-code/tests/bridge.spec.ts @@ -15,7 +15,6 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -58,7 +57,6 @@ async function harnessWithFiber( ): Promise<{ ctx: Context; hooks: Fiber }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -356,7 +354,6 @@ describe('hooks-claude-code bridge — load resilience', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -417,7 +414,6 @@ describe('hooks-claude-code bridge — load resilience', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 0f90281364..8627a970cd 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -15,7 +15,6 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentRuntime, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude-code' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' const testToolSignal = new AbortController().signal @@ -42,7 +41,6 @@ type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxC async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) @@ -363,7 +361,6 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -663,7 +660,6 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). await ctx.plugin(LocalSubprocessRuntime) @@ -693,7 +689,6 @@ export function defineCoverageCases(group: CoverageGroup): void { hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'cat > stoppayload.tmp; mv stoppayload.tmp stoppayload; pwd > stopwhere' }] }] }) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 511ec5e12a..1ef87de7c1 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -13,7 +13,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** @@ -43,7 +42,6 @@ function writeHooks(dir: string, hooks: unknown): void { async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -184,7 +182,6 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -208,7 +205,6 @@ describe('hooks-codex bridge', () => { writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] }) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 85ab765f7e..12a3c370ab 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -13,7 +13,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' const testToolSignal = new AbortController().signal @@ -32,7 +31,6 @@ type HarnessOpts = { stderrSummaryMaxChars?: number; sessionRoot?: string } async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) if (opts.sessionRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) @@ -309,7 +307,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) @@ -620,7 +617,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 7dee4cf159..4124f176fd 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -40,6 +40,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 33ea29ba68..14f4ecd847 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -1,13 +1,14 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { JobId } from '@deepseek-ai/dsh-jobs' import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart } from '@deepseek-ai/dsh-jobs' import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' declare module '@deepseek-ai/dsh-jobs' { interface JobKindMap { @@ -34,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle' as const, ctx: agentCtx, send: () => {}, @@ -44,7 +45,7 @@ function stubAgent(ctx: Context, rawId: string, presetScope?: ScopeKey): Agent { cancel() {}, runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), whenIdle() { return Promise.resolve() }, - } + } satisfies Agent agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) return agent } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 6087c98f38..02ef57fda0 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index fcf19574e3..ba218842de 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -11,7 +11,6 @@ import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-s import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as Retry from '../src/index.ts' let context: Context | undefined @@ -39,7 +38,6 @@ async function harness( vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key') const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(LlmDeepSeek, { baseURL, streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 5e4240e05b..a814b5d9af 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import PlanModeController from '@deepseek-ai/dsh-plan-mode' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 24ba27c41d..f4d46d5b62 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -64,7 +64,7 @@ function commitPlanMode(session: Session, active: boolean, turn: number): void { describe('plan projection unit', () => { it('serves inactive/not-pending for the empty log', async () => { const bench = await harness(true) - expect(bench.values()).toEqual({ plan: { active: false, pending: false } }) + expect(bench.values().plan).toEqual({ active: false, pending: false }) }) it('a logged /plan selection reads pending until plan/mode records it', async () => { diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index aebccc9b7f..e1fb150b5c 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -5,9 +5,9 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantRegistry from '@deepseek-ai/dsh-invariants' @@ -28,10 +28,10 @@ async function harness(roster: Partial = {}): Promise { ctx.loader.builtins.include = Include await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantRegistry) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 664e4c1c02..6c43c2192c 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -8,9 +8,9 @@ import Include from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -51,10 +51,10 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc ctx.loader.builtins.group = Group await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, roster) return ctx @@ -459,10 +459,10 @@ describe('the preset file is an input, never a persistence target', () => { scoped.loader.builtins.group = Group await scoped.plugin(LlmRuntime) await scoped.plugin(SessionStore) + await scoped.plugin(SessionProjectionRegistry) await scoped.plugin(SystemPrompt, { personaPrefix: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) - await scoped.plugin(SessionProjectionRegistry) await scoped.plugin(AgentLoop, { agents: [] }) await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) @@ -648,10 +648,10 @@ describe('replacing a composition', () => { scoped.loader.builtins.group = Group await scoped.plugin(LlmRuntime) await scoped.plugin(SessionStore) + await scoped.plugin(SessionProjectionRegistry) await scoped.plugin(SystemPrompt, { personaPrefix: '' }) await scoped.plugin(ToolRuntime) await scoped.plugin(AgentRegistry) - await scoped.plugin(SessionProjectionRegistry) await scoped.plugin(AgentLoop, { agents: [] }) await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) const handle = await scoped.agents.create({ diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index b7511cdbab..b1a685d019 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -79,10 +79,10 @@ async function harness( ctx.loader.builtins.include = Include await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, roster) return ctx diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts index dccc95d137..baf02e4702 100644 --- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts +++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts @@ -7,7 +7,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -52,7 +51,6 @@ async function mountRuntime(root: string, adapter: RecordingAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(PersistenceProbe) ctx.on('session/flush', () => {}) await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/schedule/schedule/tests/runtime.spec.ts b/packages/schedule/schedule/tests/runtime.spec.ts index 86b8246c32..f216babd7d 100644 --- a/packages/schedule/schedule/tests/runtime.spec.ts +++ b/packages/schedule/schedule/tests/runtime.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent' import type { UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -11,6 +11,7 @@ import { foldScheduleEvents, } from '../src/domain.ts' import { MAX_TIMER_DELAY_MS, ScheduleRuntime } from '../src/runtime.ts' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const contexts: Context[] = [] const runtimes: ScheduleRuntime[] = [] @@ -57,12 +58,11 @@ async function harness(): Promise { onFollowup: undefined as (() => void) | undefined, idle: Promise.withResolvers(), } - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: unsupportedInbox(), status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, diff --git a/packages/schedule/schedule/tests/tools.spec.ts b/packages/schedule/schedule/tests/tools.spec.ts index 4ac5d91717..35e4dc1724 100644 --- a/packages/schedule/schedule/tests/tools.spec.ts +++ b/packages/schedule/schedule/tests/tools.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-llm' @@ -10,6 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { registerScheduleTools } from '../src/tools.ts' import { runScheduleTransaction } from '../src/transaction.ts' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const signal = new AbortController().signal const contexts: Context[] = [] @@ -24,12 +25,11 @@ interface ToolHarness { function stubAgent(ctx: Context, id: string): Agent { const session = ctx.sessions.create(SessionId(id)) - const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) - return { + const agent: Agent = { id: session.id, options: {}, session, - inbox, + inbox: unsupportedInbox(), status: 'idle', ctx: new Context(), send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {}, @@ -40,6 +40,7 @@ function stubAgent(ctx: Context, id: string): Agent { steer(_message: UserMessage) {}, inject(_message: UserMessage) {}, } + return agent } async function harness(withPersistence = true): Promise { diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts index 803198ab32..e55bf76bb2 100644 --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -28,7 +28,6 @@ const [ { Context }, { default: AgentLoop }, { mountAgentLoopTestDependencies }, - { default: SessionProjectionRegistry }, { default: SubagentRuntime }, { default: JsonlSessionPersistence }, { HarnessSdkJsonRpcServer }, @@ -37,7 +36,6 @@ const [ load("vendor/cordis/lib/index.js"), load("packages/core/agent-loop/lib/index.js"), load("packages/test-support/agent-loop-testkit/lib/index.js"), - load("packages/session/session-projection/lib/index.js"), load("packages/subagent/subagent/lib/index.js"), load("packages/session/session-persistence-jsonl/lib/index.js"), load("packages/sdk/server/lib/index.js"), @@ -48,7 +46,6 @@ const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); const ctx = new Context(); try { await mountAgentLoopTestDependencies(ctx); - await ctx.plugin(SessionProjectionRegistry); await ctx.plugin(AgentLoop, { agents: [] }); await ctx.plugin(SubagentRuntime); await ctx.plugin(JsonlSessionPersistence, { root: storageRoot }); diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 8190a25eb3..e51324c019 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -11,7 +11,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' @@ -77,7 +76,6 @@ async function mountPlugin( ): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(JsonlSessionPersistence, { root: storageDir }) await new Promise(resolve => setTimeout(resolve, 50)) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index ac3af4f47b..d0bb7e1c8d 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -12,7 +12,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' @@ -65,7 +64,6 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[ async function makeHarness(storageDir: string) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(JsonlSessionPersistence, { root: storageDir }) diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts index 37caecb8dc..83826090dd 100644 --- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -2,7 +2,6 @@ import { writeFile } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createUserMessage, ToolCallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -38,7 +37,6 @@ class CrashAdapter extends LlmAdapter { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) -await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) await ctx.plugin(checkpointPolicy) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index b740065e58..1762a3a480 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: 79902cca815da1ee93916ece82cf89bb3624a3a8 -README.zh.md: 749934419cfc94a83abcb59013776fbb8b55ac19 +README.md: a837f41db31dd7db5abf721acfcb7970950056ef +README.zh.md: fa4cdf32f5b2a9ce4944502363cca5788609372f diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 79902cca81..a837f41db3 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -55,13 +55,15 @@ const definition = { ### Register and read -`register(definition)` installs the unit; the registration is an effect on the calling fiber, so unloading the domain removes its key. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's host state without computing unrelated views. +`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. ```text const dispose = ctx.sessionProjections.register(definition) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` +A domain that requires projected state declares `sessionProjections` as a Cordis service dependency; optional contributors may register under `ctx.inject(['sessionProjections'], …)`. Carriers use `ctx.get('sessionProjections')` and omit their block or frames when the registry is absent. + ### Persisted checkpoints Every unit's state is checkpointed — client-visible and host-only alike — through `checkpoint(session)`, and the sibling [session-projection-cache](../session-projection-cache/README.md) persists those checkpoints so cold reads skip full log loads. Checkpoint watermarks use `SessionSeqCursor` (`-1` for an empty log), while replay starts use `SessionLogOffset`; `restoreFloor` and `restore` implement the read recipe without conflating an existing event with a log gap. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 749934419c..fa4cdf32f5 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -55,13 +55,15 @@ const definition = { ### 注册与读取 -`register(definition)` 安装单元;注册是挂在调用方 fiber 上的 effect,因此卸载领域即移除其 key。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的主机状态,不计算无关视图。 +`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 ```text const dispose = ctx.sessionProjections.register(definition) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` +必须使用投影状态的领域把 `sessionProjections` 声明为 Cordis 服务依赖;可选贡献方可以在 `ctx.inject(['sessionProjections'], …)` 下注册。载体使用 `ctx.get('sessionProjections')`,注册表缺席时省略自己的块或帧。 + ### 持久检查点 每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁——通过 `checkpoint(session)`,同级包 [session-projection-cache](../session-projection-cache/README.zh.md) 持久化这些检查点,使冷读跳过全量日志加载。检查点水位使用 `SessionSeqCursor`(空日志为 `-1`),回放起点使用 `SessionLogOffset`;`restoreFloor` 与 `restore` 在无活动会话的情况下实现读取配方,且不会混淆已有事件与日志间隙。 diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index f341afd520..8eae8597a3 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index a9f4d613ca..82895a008c 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' @@ -20,6 +20,7 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' let root: string | undefined let context: Context | undefined @@ -47,7 +48,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index c7a406663f..3afbecfec3 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -18,6 +18,7 @@ import type { import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const contexts: Context[] = [] let callNumber = 0 @@ -40,7 +41,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index f06f4d22f3..cf2a4e1d2e 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -6,7 +6,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -27,8 +26,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - // AgentLoop declares the registry as a required injection. - await ctx.plugin(SessionProjectionRegistry) if (sessionRoot !== undefined) { await ctx.plugin(JsonlSessionPersistence, { root: sessionRoot, compression: 'none' }) } diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 227c349963..5089ec937b 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 74bc8c0514..05116a6b5c 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -9,8 +9,8 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash' @@ -22,6 +22,7 @@ import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const hasPwsh = spawnSync( resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], @@ -54,7 +55,7 @@ function agent(ctx: Context, cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 4190bdb604..e65859e4c8 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { @@ -18,6 +18,7 @@ import type { import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const contexts: Context[] = [] let callNumber = 0 @@ -40,7 +41,7 @@ function agent(ctx: Context, cwd: string | undefined): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index d028a3ca8c..49be90ea89 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index ffcb54268b..29de7d0bd7 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -10,10 +10,11 @@ import { } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const testToolSignal = new AbortController().signal @@ -56,7 +57,7 @@ function agentForCwd(cwd: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', send: () => {}, followup: () => {}, @@ -69,11 +70,11 @@ function agentForCwd(cwd: string): Agent { } function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { - return { + const agent: Agent = { id: SessionId(id), options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'running', ctx: new Context(), send: () => {}, @@ -84,6 +85,7 @@ function sessionAgent(session: Session, id = 'tool-skill-agent'): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + return agent } function openMessageTurn(session: Session, turn = 1): void { diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts index e761f070af..35c8af4df3 100644 --- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts @@ -9,7 +9,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as fork from '../src/index.ts' @@ -38,7 +37,6 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts index ac9a994c29..6f7080cd31 100644 --- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts +++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts @@ -45,7 +45,6 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts index 4d86cb3705..abc09ff53d 100644 --- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts @@ -18,7 +18,6 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import ApprovalService from '@deepseek-ai/dsh-user-approval' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -41,7 +40,6 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }) await ctx.plugin(SandboxedFileSystem, { cwd: workspace }) await ctx.plugin(ToolFs) diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts index d34729037c..d3a88e5572 100644 --- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts @@ -18,7 +18,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentPresets from '@deepseek-ai/dsh-agent-presets' import { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -40,7 +39,6 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; await ctx.plugin(Loader) ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeShippedRoot: false, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 2b5350d951..c529459b82 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -13,7 +13,6 @@ import SubagentRuntime, { type ResolvedSubagentStartRequest, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -69,7 +68,6 @@ async function setup(script: Script, options: SetupOptions = {}) { } await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const disposeProvider = ctx.subagents.registerProvider({ name: 'spawn', diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts index c669e614b6..d257c3d442 100644 --- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts @@ -10,7 +10,6 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -29,7 +28,6 @@ async function setup(script: Script, parentOptions: Partial = {}) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const adapter = new MockAdapter(script) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts index 89606e923b..03442bfb11 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts @@ -1,7 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' @@ -25,7 +24,6 @@ export async function spawnHarness(workdir: string): Promise { // spawned children render it. It stays neutral for both roles; the // delegation nudge lives in the e2e's user prompt and the subagent tool's // own description. - await ctx.plugin(SessionProjectionRegistry) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: 'You are a coding agent. Report only when the requested work is done.' }, }) diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts index dc9d3ed4d1..21081b417c 100644 --- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts +++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts @@ -39,7 +39,6 @@ async function setup(script: Script) { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -330,7 +329,6 @@ describe('dsh-subagent-spawn-in-process', () => { await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -359,7 +357,6 @@ describe('dsh-subagent-spawn-in-process', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) diff --git a/packages/subagent/subagent/src/inbox.ts b/packages/subagent/subagent/src/inbox.ts index 8800e3ce2b..e610db2014 100644 --- a/packages/subagent/subagent/src/inbox.ts +++ b/packages/subagent/subagent/src/inbox.ts @@ -35,7 +35,7 @@ export class SubagentInbox { * @returns whether either Agent inbox destination is non-empty. */ get hasPending(): boolean { - return this.agent.inbox.hasPending + return this.agent.inbox.nextTurn.length > 0 || this.agent.inbox.nextStep.length > 0 } /** diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index d1dde79a41..9e3a6989c6 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -19,7 +19,6 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -43,7 +42,6 @@ async function setup(script: Script) { const ctx = new Context() contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) roots.push(root) await ctx.plugin(JsonlSessionPersistence, { root }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 8e082debe4..a99ac93e71 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -9,7 +9,6 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import type { ContentBlock, GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -80,9 +79,6 @@ async function setupWith( ) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - // The registry is a required injection of AgentLoop and SubagentRuntime - // (both register projection units on activation). - await ctx.plugin(SessionProjectionRegistry) let disposePersistence: (() => Promise) | undefined let root: string | undefined if (options.persistence !== false) { @@ -527,7 +523,6 @@ describe('SubagentRuntime.startContinuable', () => { const fresh = new Context() await mountAgentLoopTestDependencies(fresh) - await fresh.plugin(SessionProjectionRegistry) const freshPersistence = await fresh.plugin(JsonlSessionPersistence, { root: root! }) // This context opened a second handle on the same root; register it so // afterEach closes it before removing the root (even on a failure path). @@ -3235,7 +3230,6 @@ describe('continuable errors', () => { const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }]) const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-')) const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root }) cleanups.push(async () => { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index c8b526920d..a9e34f9ffc 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -47,7 +47,7 @@ afterEach(async () => { /** Boot the continuable stack with real JSONL session persistence. */ async function setup( script: Script, - options: { sessionProjections?: boolean; projectionCache?: boolean } = {}, + options: { projectionCache?: boolean } = {}, ) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) @@ -56,7 +56,6 @@ async function setup( const persistence = await ctx.plugin(JsonlSessionPersistence, { root }) persistenceDisposers.push(() => persistence.dispose()) await ctx.plugin(AgentLoop, { agents: [] }) - if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry) if (options.projectionCache === true) { const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-projcache-')) projCacheRoots.push(root) @@ -82,6 +81,13 @@ async function setup( return { ctx, parent } } +async function setupWithoutProjections(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SubagentRuntime) + return ctx +} + const testSignal = new AbortController().signal /** Start one continuable child through the real service path and await Activation release. */ @@ -246,8 +252,8 @@ describe('SubagentRuntime.listChildren', () => { }) it('fails loud when the projection registry is not mounted, even with no children', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow( + const ctx = await setupWithoutProjections() + await expect(ctx.subagents.listChildren(SessionId('no-projections-parent'))).rejects.toThrow( expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, ) }) @@ -1108,8 +1114,9 @@ describe('SubagentRuntime.listChildren', () => { }) it('SubagentError from listChildren is typed with its stable code', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error) + const ctx = await setupWithoutProjections() + const caught: unknown = await ctx.subagents.listChildren(SessionId('typed-no-projections-parent')) + .catch((error: unknown) => error) expect(caught).toBeInstanceOf(SubagentError) expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') }) @@ -1344,8 +1351,8 @@ describe('SubagentRuntime.listDescendants', () => { }) it('fails loud when the projection registry is not mounted', async () => { - const { ctx, parent } = await setup([], { sessionProjections: false }) - await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow( + const ctx = await setupWithoutProjections() + await expect(ctx.subagents.listDescendants(SessionId('no-projections-root'))).rejects.toThrow( expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error, ) }) diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 8b190d8092..b21de72a90 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -8,7 +8,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry } from '@deepseek-ai/dsh-subagent' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -60,7 +59,6 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) { await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(tool) @@ -251,7 +249,6 @@ describe('dsh-tool-subagent-control/list-agents', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true) diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index da7fe7a51b..c1cb00e114 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -9,7 +9,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -62,7 +61,6 @@ async function setupWith(adapter: MockAdapter | GatedAdapter, park = true) { await ctx.plugin(JsonlSessionPersistence, { root }) await ctx.plugin(TestSessionQuery) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -333,7 +331,6 @@ describe('dsh-tool-subagent-control', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const fiber = await ctx.plugin(tool) expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true) diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index acd1b02c7c..bda0a5ae63 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -50,7 +50,6 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -327,7 +326,6 @@ describe('SubagentModelSelectionConfig', () => { it('requires both the Host setting owner and a composition scope', async () => { const withoutSettings = new Context() await mountAgentLoopTestDependencies(withoutSettings) - await withoutSettings.plugin(SessionProjectionRegistry) await withoutSettings.plugin(SubagentRuntime) expect(() => { tool.apply(withoutSettings, { diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index e5f9502a43..1e79190deb 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1182,7 +1182,7 @@ describe('dsh-tool-subagent continuable background mode', () => { /** Boot the real continuable stack without any model-facing follow-up adapter. */ async function continuableSetup() { - const ctx = await projectedContext() + const ctx = new Context() await mountAgentLoopTestDependencies(ctx) const root = mkdtempSync(path.join(tmpdir(), 'dsh-tool-subagent-continuable-')) roots.push(root) diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index b9e15ff0d1..e378385dc5 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -43,6 +43,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index ff9bb91afb..af707d8269 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -23,6 +23,7 @@ import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' class EmptySandbox extends SandboxProvider { confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { @@ -54,7 +55,7 @@ function agent(ctx: Context, cwd?: string): Agent { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false, ...cwd === undefined ? {} : { cwd }, }) return { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx, send: () => {}, @@ -593,7 +594,7 @@ describe('terminal-bash plugin shape', () => { const session = ctx.sessions.create(SessionId('mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: ownerFiber.ctx, send: () => {}, @@ -643,7 +644,7 @@ describe('terminal-bash plugin shape', () => { const session = ctx.sessions.create(SessionId('pending-mode-owner')) const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { - id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id: session.id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: ownerFiber.ctx, send: () => {}, diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 1df9b7cb9e..9800a79d54 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal' @@ -16,6 +16,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const roots: string[] = [] const contexts: Context[] = [] @@ -38,8 +39,8 @@ function stubAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const scope = ctx.plugin(() => {}) const session = Session.create(id) - return { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + const agent: Agent = { + id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, @@ -47,6 +48,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { runMaintenance: task => task(new AbortController().signal), whenIdle: () => Promise.resolve(), } + return agent } async function harness( diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 60b671464e..48ca98a847 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/terminal/terminal/tests/service.spec.ts b/packages/terminal/terminal/tests/service.spec.ts index 10484d3c5c..c5c552d18f 100644 --- a/packages/terminal/terminal/tests/service.spec.ts +++ b/packages/terminal/terminal/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TerminalSessionService, { TerminalBackendCleanupError, TerminalError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { @@ -14,6 +14,7 @@ import type { TerminalSessionStatus, TerminalSignal, } from '@deepseek-ai/dsh-terminal' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' const agentScopeDisposers = new WeakMap Promise>() const ptyServiceDisposers = new WeakMap Promise>() @@ -26,7 +27,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { id, options: {}, session, - inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + inbox: unsupportedInbox(), status: 'idle', ctx: scopeFiber.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 5a69158158..dd09568343 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts index 6c4fe65e6a..c6f8f494de 100644 --- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts @@ -8,7 +8,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -20,6 +20,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' let root: string | undefined let context: Context | undefined @@ -42,7 +43,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const session = Session.create(id) const value: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 15151f7bd2..8c0c7f6292 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' @@ -12,13 +12,14 @@ import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, Te import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' import * as ToolPty from '@deepseek-ai/dsh-tool-terminal' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' function fakeAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) const id = SessionId(rawId) const session = Session.create(id) const agent: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, send: () => {}, diff --git a/packages/test-support/agent-loop-testkit/README.i18n.yaml b/packages/test-support/agent-loop-testkit/README.i18n.yaml index 7a4efa8491..3f0298285b 100644 --- a/packages/test-support/agent-loop-testkit/README.i18n.yaml +++ b/packages/test-support/agent-loop-testkit/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/agent-loop-testkit/README.md -README.md: 7b935594636e265bf4f808f692bba2fae0f83ddf -README.zh.md: fc9dc688a1e7d4303f7744ed47c7da2d0fbbf67f +README.md: 7c77cf77cf20817795f43c3493bff8e5c7ccd900 +README.zh.md: 0862c60e7b40a8f63925b87bc41377b12ac2d788 diff --git a/packages/test-support/agent-loop-testkit/README.md b/packages/test-support/agent-loop-testkit/README.md index 7b93559463..7c77cf77cf 100644 --- a/packages/test-support/agent-loop-testkit/README.md +++ b/packages/test-support/agent-loop-testkit/README.md @@ -1,5 +1,5 @@ --- -description: "Shared service mounting for tests that exercise the concrete AgentLoop, for test authors wiring real loop prerequisites." +description: "Prerequisite mounting, production AgentLoop drivers, and explicit Inbox stubs for agent-loop tests." kind: "package-library" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-agent-loop-testkit` mounts the standard prerequisite services a test needs before loading the concrete `AgentLoop` — the LLM runtime, session store, system-prompt registry, tool registry, and agent registry — in dependency order, with one call. The loop itself, adapters, optional plugins, agents, and teardown stay in the test's hands, so each scenario keeps its own load order and topology. Use it when a test's subject is loop behavior rather than service wiring; tests that probe injection failures or partial topologies mount their dependencies directly. It registers no model-facing behavior of its own. +`dsh-agent-loop-testkit` mounts the standard prerequisite services a test needs before loading the concrete `AgentLoop` — the LLM runtime, session store, session-projection registry, system-prompt registry, tool registry, and agent registry — in dependency order, with one call. A second helper mounts the production loop and returns a narrow driver for creating real Agents and claiming their real Inbox input. Consumer tests that need only the public queue operations can instead use an explicitly process-local Inbox stub, while tests with no pending-input behavior can use a fail-fast unsupported Inbox. Adapters, optional plugins, load order, and teardown stay in the test's hands. The package registers no model-facing behavior of its own. ## Table of Contents @@ -25,31 +25,54 @@ English | [中文](README.zh.md) ## Use this package -This package gives an AgentLoop test a working service topology before the loop is mounted: call the helper on your test context, then mount `AgentLoop` with the configuration under test and register your adapter and optional plugins. +This package gives an AgentLoop test a working service topology and keeps the choice between production Inbox behavior and a structural stub explicit. -### Minimal example +### Drive a production Agent + +Use `mountAgentLoopTestHarness()` when the test covers durable Inbox events, projection recovery or validation, live Inbox notifications, or loop-driver claims. Mount any load-order-sensitive consumers after the prerequisites and before creating the Agent. The context owns the loop and every Agent returned by the harness. ```ts import { Context } from '@deepseek-ai/cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' const ctx = new Context() await mountAgentLoopTestDependencies(ctx) -// Register the test adapter and any optional plugins here. -await ctx.plugin(AgentLoop, { agents: [] }) +// Register the test adapter and any load-order-sensitive plugins here. +const harness = await mountAgentLoopTestHarness(ctx) +const agent = await harness.create(SessionId('test-agent')) +declare const message: UserMessage + +agent.inbox.append('next-turn', message) +const admitted = harness.claim(agent, 'next-turn', 1) ``` -The helper activates the LLM, session, system-prompt, tool, and agent services in dependency order and returns before the loop is mounted. System-prompt and tool-registry configuration can be forwarded through `options`; the helper provides no test defaults beyond those the services own. +The dependency helper forwards system-prompt and tool-registry configuration through `options` and provides no test defaults beyond those services' own defaults. A plugin-load failure rejects the helper call; services activated earlier in the sequence remain context-owned and unwind when the context is disposed. + +### Build a structural Agent stub + +Use `createInboxStub()` when the test subject needs mutable pending lists but does not exercise durability, projection validation, live Inbox notifications, or the driver's claim policy. The stub implements the public queue operations with two process-local arrays and never writes to a Session. Use `unsupportedInbox()` when the test subject must not touch pending input; every mutation throws at the first unexpected dependency. + +```ts +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' + +const agent = { + // ... + inbox: createInboxStub(), +} +``` ### When to use it -Use the helper for tests whose subject is the loop: load order, retries, tool execution, or session behavior on a real prerequisite stack. Mount dependencies directly when a test probes service load order, injection failures, partial topologies, or teardown — the helper hides exactly the wiring such tests must control. +Use the dependency and loop helpers for tests whose subject is production loop or durable Inbox behavior. Use the structural stub for consumer-domain tests that only need queue editing. Mount dependencies directly when a test probes service injection failures or partial topologies, because the helper hides exactly the wiring those tests must control. ### What can go wrong -A plugin-load failure rejects the helper call; services activated earlier in the sequence remain owned by your context and unwind with it. The context owns every mounted service, so dispose it after the test. +The harness mounts no LLM adapter. Register an adapter before sending work that would start a model request. Dispose the owning context after every test so Agents reach quiescence and their scoped registrations unwind. ----- @@ -59,11 +82,11 @@ A plugin-load failure rejects the helper call; services activated earlier in the
    Implementation internals — click to expand -This section explains the design of the helper; the observable behavior is fully covered in [Use this package](#use-this-package). +This section explains the design of the test utilities; the observable behavior is fully covered in [Use this package](#use-this-package). ### Design -**Runtime invariant:** No companion is published. This test-support package owns no production event stream or mutable data; consuming test suites exercise its behavior. +`mountAgentLoopTestDependencies` mounts six service plugins in a fixed dependency order — LLM, session, session-projection registry, system-prompt registry, tool registry, then agent registry — and stops before `AgentLoop`, so the caller controls loop load order. `mountAgentLoopTestHarness` mounts the public production plugin, creates Agents through its service, and exposes the production driver's claim operation without exporting the loop's concrete Inbox class or projection definition. [`src/inbox.ts`](src/inbox.ts) contains only the process-local mutable stub and the fail-fast unsupported placeholder; it owns no projection or durable event implementation. The mounting and driver implementation lives in [`src/index.ts`](src/index.ts). No invariant companion is published because the package owns only test helpers and has no independent production observations that can diverge.
    @@ -72,11 +95,11 @@ This section explains the design of the helper; the observable behavior is fully ## Further Exploration -Read these pages when the package-level contract is not enough. They move from the loop to the services the helper mounts and the tests that use it. +Read these pages when the package-level behavior is not enough. They move from the loop to the services the helper mounts and the tests that use it. -- [Agent loop package](../../core/agent-loop/README.md) — the concrete loop this helper prepares tests for. -- [Session package](../../core/session/README.md) — the session store the helper mounts. -- [LLM package](../../llm/llm/README.md) — the LLM runtime and adapter contract the helper mounts. +- [Agent loop package](../../core/agent-loop/README.md) — the concrete loop this helper mounts for production behavior. +- [Session package](../../core/session/README.md) — the durable event log used by production Inbox behavior. +- [LLM package](../../llm/llm/README.md) — the LLM runtime and adapter interface the helper prepares. - [Testing policy](../../../docs/testing.md) — the coverage tiers these tests serve. - [Test-support group map](../README.md) — sibling harnesses and support packages. @@ -85,20 +108,22 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -None, as this test-only composition helper neither drives nor modifies model requests. +None, as these test-only utilities neither assemble nor modify model requests. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +None; the package itself sends no provider request. ## Known Limitations and Deferred Work +These limits define what the utilities do not share. They are current package constraints, not a task backlog. -These limits define what the helper does not share. They are current package constraints, not a task backlog. - -- **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, `AgentLoop`, agents, and context teardown remain caller-owned so scenario-specific ordering stays visible. +- **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, scenario-specific load order, and context teardown remain caller-owned. +- **The production harness has no adapter default** — tests that start the loop must register the route they exercise. +- **The mutable Inbox stub is process-local only** — use a harness-created Agent whenever durable events, projection recovery or validation, live notifications, or claim policy matter. +- **The unsupported Inbox accepts no mutations** — use the mutable stub or a harness-created Agent whenever pending input is part of the test subject. ### Dev Note diff --git a/packages/test-support/agent-loop-testkit/README.zh.md b/packages/test-support/agent-loop-testkit/README.zh.md index fc9dc688a1..0862c60e7b 100644 --- a/packages/test-support/agent-loop-testkit/README.zh.md +++ b/packages/test-support/agent-loop-testkit/README.zh.md @@ -1,5 +1,5 @@ --- -description: "为运行具体 AgentLoop 的测试挂载共享服务先决依赖,面向接线真实循环前置依赖的测试作者。" +description: "为 agent-loop 测试提供先决依赖挂载、生产 AgentLoop 驱动与职责明确的 Inbox 桩。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的全部标准先决服务——LLM(大语言模型)运行时、会话存储、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。loop 本身、适配器、可选插件、agent 与清理仍由测试掌控,因此每个场景都保持自己的加载顺序与拓扑。当测试对象是 loop 行为而非服务接线时使用它;针对注入失败或部分拓扑的测试会直接挂载其依赖。它自身不注册任何模型可见行为。 +`dsh-agent-loop-testkit` 为测试在加载具体 `AgentLoop` 之前所需的标准先决服务——LLM(大语言模型)运行时、会话存储、会话投影注册表、系统提示词注册表、工具注册表与 agent(智能体)注册表——按依赖顺序一键挂载。另一个辅助函数会挂载生产 loop,并返回一个精简驱动,用于创建真实 Agent 和通过真实 Inbox 认领输入。只需要公开队列操作的消费方测试可以改用明确标记为进程内实现的 Inbox 桩;不涉及待处理输入的测试则可以使用快速失败且不支持操作的 Inbox。适配器、可选插件、加载顺序与清理由测试掌控。本包自身不注册任何模型可见行为。 ## 目录 @@ -25,31 +25,54 @@ kind: "package-library" ## 使用本包 -本包在 loop 挂载前为 AgentLoop 测试提供可用的服务拓扑:在测试上下文上调用此辅助函数,然后用待测配置挂载 `AgentLoop`,并注册你的适配器与可选插件。 +本包为 AgentLoop 测试提供可用的服务拓扑,并要求测试明确选择生产 Inbox 行为或结构化桩。 -### 最小示例 +### 驱动生产 Agent + +当测试覆盖持久 Inbox 事件、投影恢复或校验、实时 Inbox 通知,或 loop 驱动的认领策略时,使用 `mountAgentLoopTestHarness()`。应在挂载先决依赖后、创建 Agent 前挂载所有对加载顺序敏感的消费方。上下文拥有 loop 以及该 harness 返回的每个 Agent。 ```ts import { Context } from '@deepseek-ai/cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId, type UserMessage } from '@deepseek-ai/dsh-session' +import { + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, +} from '@deepseek-ai/dsh-agent-loop-testkit' const ctx = new Context() await mountAgentLoopTestDependencies(ctx) -// Register the test adapter and any optional plugins here. -await ctx.plugin(AgentLoop, { agents: [] }) +// Register the test adapter and any load-order-sensitive plugins here. +const harness = await mountAgentLoopTestHarness(ctx) +const agent = await harness.create(SessionId('test-agent')) +declare const message: UserMessage + +agent.inbox.append('next-turn', message) +const admitted = harness.claim(agent, 'next-turn', 1) ``` -该辅助函数按依赖顺序激活 LLM、会话、系统提示词、工具与 agent 服务,并在 loop 挂载前返回。系统提示词与工具注册表配置可通过 `options` 转发;除服务自有的默认值外,本辅助函数不提供测试默认值。 +依赖辅助函数通过 `options` 转发系统提示词与工具注册表配置,除这些服务自有的默认值外不提供测试默认值。插件加载失败会使辅助函数调用被拒绝;顺序中较早激活的服务仍归上下文所有,并在上下文释放时一并解除。 + +### 构造结构化 Agent 桩 + +当测试对象需要可变的待处理列表,但不测试持久性、投影校验、实时 Inbox 通知或驱动的认领策略时,使用 `createInboxStub()`。该桩通过两个进程内数组实现公开队列操作,且绝不会写入 Session。当测试对象不应访问待处理输入时,使用 `unsupportedInbox()`;每次变更都会在首个意外依赖处抛错。 + +```ts +import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' + +const agent = { + // ... + inbox: createInboxStub(), +} +``` ### 何时使用 -当测试对象是 loop 本身——在真实先决依赖栈上的加载顺序、重试、工具执行或会话行为——时使用此辅助函数。当测试要探测服务加载顺序、注入失败、部分拓扑或清理时,请直接挂载依赖——辅助函数隐藏的正是这类测试必须控制的接线。 +当测试对象是生产 loop 或持久 Inbox 行为时,使用依赖与 loop 辅助函数。只需要编辑队列的消费方领域测试使用结构化桩。当测试探测服务注入失败或部分拓扑时,请直接挂载依赖,因为辅助函数隐藏的正是这类测试必须控制的接线。 ### 可能出什么问题 -插件加载失败会使辅助函数调用被拒绝;顺序中较早激活的服务仍归你的上下文所有,并随上下文一起解除。上下文拥有所有已挂载服务,因此测试结束后请 dispose(资源释放)它。 +harness 不会挂载任何 LLM 适配器。若测试发送的任务会启动模型请求,请先注册被测路由的适配器。每个测试结束后都应释放所属上下文,使 Agent 达到静止状态并解除其作用域注册。 ----- @@ -59,11 +82,11 @@ await ctx.plugin(AgentLoop, { agents: [] })
    实现细节——点击展开 -本节解释辅助函数的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。 +本节解释测试辅助工具的设计;可观察行为已在[使用本包](#use-this-package)中完整说明。 ### 设计 -**运行时不变式:** 不发布伴生入口。本包不持有生产事件流或可变数据;消费它的测试套件会直接检验 harness 行为。 +`mountAgentLoopTestDependencies` 按固定依赖顺序——LLM、会话、会话投影注册表、系统提示词注册表、工具注册表、agent 注册表——挂载六个服务插件,并在 `AgentLoop` 之前停下,使调用方控制 loop 加载顺序。`mountAgentLoopTestHarness` 挂载公开的生产插件,通过其服务创建 Agent,并公开生产驱动的认领操作,而不导出 loop 的具体 Inbox 类或投影定义。[`src/inbox.ts`](src/inbox.ts) 仅包含进程内可变桩和快速失败且不支持操作的占位值;它不持有投影或持久事件实现。挂载与驱动实现位于 [`src/index.ts`](src/index.ts)。本包不发布 invariant companion,因为它只持有测试辅助工具,不存在可能相互偏离的独立生产观测。
    @@ -72,11 +95,11 @@ await ctx.plugin(AgentLoop, { agents: [] }) ## 进一步探索 -当包级约定不够用时阅读以下页面。它们从 loop 逐步进入辅助函数挂载的服务以及使用它的测试。 +当包级行为不够用时阅读以下页面。它们从 loop 逐步进入辅助函数挂载的服务以及使用它的测试。 -- [Agent loop 包](../../core/agent-loop/README.zh.md)——本辅助函数为之准备测试的具体 loop。 -- [会话包](../../core/session/README.zh.md)——辅助函数挂载的会话存储。 -- [LLM 包](../../llm/llm/README.zh.md)——辅助函数挂载的 LLM 运行时与适配器约定。 +- [Agent loop 包](../../core/agent-loop/README.zh.md)——本辅助函数为生产行为挂载的具体 loop。 +- [会话包](../../core/session/README.zh.md)——生产 Inbox 行为使用的持久事件日志。 +- [LLM 包](../../llm/llm/README.zh.md)——本辅助函数准备的 LLM 运行时与适配器接口。 - [测试策略](../../../docs/testing.zh.md)——这些测试所服务的覆盖层级。 - [test-support 组地图](../README.zh.md)——兄弟 harness 与支持包。 @@ -85,20 +108,22 @@ await ctx.plugin(AgentLoop, { agents: [] }) ## 模型体验 -无。该测试专用组合辅助函数既不驱动也不修改模型请求。 +无。这些测试专用辅助工具既不组装也不修改模型请求。 #### KV Cache 影响 -无;本包既不组装也不发送提供方请求。 +无;本包自身不发送提供方请求。 ## 已知限制与延期工作 +这些限制说明辅助工具不共享什么。它们是当前包约束,不是任务积压。 -这些限制说明辅助函数不共享什么。它们是当前包约束,不是任务积压。 - -- **只共享必需的先决主干**——适配器、可选插件、`AgentLoop`、agent 与上下文清理仍由调用方负责,以使特定场景的挂载顺序清晰可见。 +- **只共享必需的先决主干**——适配器、可选插件、场景特定的加载顺序与上下文清理仍由调用方负责。 +- **生产 harness 没有适配器默认值**——启动 loop 的测试必须注册其实际使用的路由。 +- **可变 Inbox 桩仅存在于进程内**——只要持久事件、投影恢复或校验、实时通知或认领策略属于测试对象,就应使用 harness 创建的 Agent。 +- **不支持操作的 Inbox 不接受变更**——只要待处理输入属于测试对象,就应使用可变桩或 harness 创建的 Agent。 ### 开发备注 diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 2ea2941896..fc123b8bac 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", - "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", + "description": "Prerequisite mounting, production AgentLoop drivers, and Inbox stubs for tests", "version": "0.1.3-alpha.2", "publishConfig": { "access": "public" @@ -28,17 +28,21 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": {}, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/test-support/agent-loop-testkit/src/inbox.ts b/packages/test-support/agent-loop-testkit/src/inbox.ts new file mode 100644 index 0000000000..dbb1dbff57 --- /dev/null +++ b/packages/test-support/agent-loop-testkit/src/inbox.ts @@ -0,0 +1,74 @@ +import type { Inbox, InboxTarget } from '@deepseek-ai/dsh-agent' +import type { MessageId } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-session' + +/** + * Create a mutable in-memory Inbox stub for tests that exercise only the public + * queue operations. Durable events, projection validation, and live Inbox + * notifications require a real Agent created by the AgentLoop test harness. + * @returns an Inbox backed by two process-local arrays. + */ +export function createInboxStub(): Inbox { + const pending: Record = { + 'next-turn': [], + 'next-step': [], + } + + const locate = (messageId: MessageId): { target: InboxTarget; index: number } | undefined => { + for (const target of ['next-turn', 'next-step'] as const) { + const index = pending[target].findIndex(message => message.id === messageId) + if (index >= 0) return { target, index } + } + return undefined + } + + return { + get nextTurn() { return pending['next-turn'] }, + get nextStep() { return pending['next-step'] }, + clear() { + pending['next-step'].splice(0) + pending['next-turn'].splice(0) + }, + append(target, message) { + pending[target].push(message) + }, + prepend(target, message) { + pending[target].unshift(message) + }, + replace(messageId, message) { + const location = locate(messageId) + if (location === undefined) return false + pending[location.target].splice(location.index, 1, message) + return true + }, + remove(messageId) { + const location = locate(messageId) + if (location === undefined) return false + pending[location.target].splice(location.index, 1) + return true + }, + splice(target, start, deleteCount, inserted) { + return pending[target].splice(start, deleteCount, ...inserted) + }, + } +} + +/** + * Create an unsupported Inbox placeholder for Agent stubs whose tests do not exercise Inbox behavior. + * @returns an Inbox whose pending lists are empty and whose mutation methods throw. + */ +export function unsupportedInbox(): Inbox { + const rejectMutation = (): never => { + throw new Error('this test Agent does not support Inbox mutations') + } + return { + nextTurn: [], + nextStep: [], + clear: rejectMutation, + append: rejectMutation, + prepend: rejectMutation, + replace: rejectMutation, + remove: rejectMutation, + splice: rejectMutation, + } +} diff --git a/packages/test-support/agent-loop-testkit/src/index.ts b/packages/test-support/agent-loop-testkit/src/index.ts index e2ae19653d..0e9b57e666 100644 --- a/packages/test-support/agent-loop-testkit/src/index.ts +++ b/packages/test-support/agent-loop-testkit/src/index.ts @@ -1,19 +1,49 @@ /** - * Shared mounting for the services required before tests load the concrete - * agent loop. The caller retains ownership of the context, loop, adapters, - * optional plugins, and teardown. + * Shared service mounting, real AgentLoop drivers, and structural Inbox stubs + * for agent-loop tests. Callers retain ownership of their contexts, adapters, + * optional plugins, agents, and teardown. * @module @deepseek-ai/dsh-agent-loop-testkit */ import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions, Inbox, InboxTarget } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { Config as ToolRuntimeConfig } from '@deepseek-ai/dsh-tools' +export { createInboxStub, unsupportedInbox } from './inbox.ts' + +interface DriverInbox extends Inbox { + claim(target: InboxTarget, turn: number): UserMessage[] +} + +/** Test driver for production Agents created by a mounted AgentLoop. */ +export interface AgentLoopTestHarness { + /** + * Create a production Agent and fresh Session owned by the harness context. + * @param id - shared Agent and Session identity. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published production Agent after creation completes. + */ + create(id: SessionId, options?: AgentOptions, meta?: Pick): Promise + /** + * Admit pending messages through the production loop driver's claim operation. + * @param agent - Agent returned by this harness's `create` method. + * @param target - boundary whose pending input is admitted. + * @param turn - turn that owns the admitted messages. + * @returns next-step messages followed by one next-turn message when requested. + */ + claim(agent: Agent, target: InboxTarget, turn: number): UserMessage[] +} + /** Configuration forwarded to the prerequisite service plugins. */ export interface AgentLoopTestDependenciesOptions { /** Configuration for the system-prompt registry. */ @@ -40,7 +70,24 @@ export async function mountAgentLoopTestDependencies( ): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) await ctx.plugin(ToolRuntime, options.tools ?? {}) await ctx.plugin(AgentRegistry) } + +/** + * Mount the production AgentLoop and expose its narrow test-driver operations. + * Mount {@link mountAgentLoopTestDependencies} and any load-order-sensitive + * consumers before calling this helper. The context owns the loop and every + * Agent returned by the harness. + * @param ctx - test context with the AgentLoop prerequisite services active. + * @returns a driver that creates production Agents and claims their real Inbox. + */ +export async function mountAgentLoopTestHarness(ctx: Context): Promise { + await ctx.plugin(AgentLoop, { agents: [] }) + return { + create: async (id, options = {}, meta = {}) => ctx.agentLoop.create(id, options, meta), + claim: (agent, target, turn) => (agent.inbox as DriverInbox).claim(target, turn), + } +} diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index c715ac03cc..f4235aad18 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -1,11 +1,29 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import { mountAgentLoopTestDependencies } from '../src/index.ts' +import { + createInboxStub, + mountAgentLoopTestDependencies, + mountAgentLoopTestHarness, + unsupportedInbox, +} from '../src/index.ts' + +function message(text: string) { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +} describe('dsh-agent-loop-testkit', () => { - it('mounts a configurable prerequisite spine that can activate AgentLoop', async () => { + it('rejects mutations through an unsupported Agent stub Inbox', () => { + const inbox = unsupportedInbox() + + expect(inbox.nextTurn).toEqual([]) + expect(inbox.nextStep).toEqual([]) + expect(() => { inbox.clear() }).toThrow('this test Agent does not support Inbox mutations') + }) + + it('mounts a configurable prerequisite spine and the production AgentLoop', async () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: 'Test persona.' }, @@ -13,7 +31,77 @@ describe('dsh-agent-loop-testkit', () => { }) expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Test persona.') - await expect(ctx.plugin(AgentLoop, { agents: [] })).resolves.toBeDefined() + await expect(mountAgentLoopTestHarness(ctx)).resolves.toBeDefined() + + await ctx.fiber.dispose() + }) + + it('provides a mutable in-memory Inbox stub for structural Agent tests', () => { + const inbox = createInboxStub() + const firstTurn = message('first turn') + const secondTurn = message('second turn') + const firstStep = message('first step') + const editedTurn = message('edited turn') + const editedStep = message('edited step') + + inbox.append('next-turn', firstTurn) + inbox.prepend('next-turn', secondTurn) + inbox.append('next-step', firstStep) + expect(inbox.nextTurn).toEqual([secondTurn, firstTurn]) + expect(inbox.nextStep).toEqual([firstStep]) + + expect(inbox.replace(firstTurn.id, editedTurn)).toBe(true) + expect(inbox.replace(firstStep.id, editedStep)).toBe(true) + expect(inbox.replace(firstTurn.id, message('missing replacement'))).toBe(false) + expect(inbox.remove(firstTurn.id)).toBe(false) + expect(inbox.splice('next-turn', -1, 1, [])).toEqual([editedTurn]) + expect(inbox.remove(editedStep.id)).toBe(true) + + inbox.clear() + expect(inbox.nextTurn).toEqual([]) + expect(inbox.nextStep).toEqual([]) + }) + + it('drives durable Inbox behavior through a production Agent', async () => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + const harness = await mountAgentLoopTestHarness(ctx) + const agent = await harness.create(SessionId('agent-loop-testkit-inbox')) + const turn = message('turn') + const step = message('step') + const inserted: string[] = [] + const claimed: Array<{ id: string; turn: number }> = [] + ctx.on('agent/inbox/inserted', ({ agent: subject, message: pending }) => { + if (subject === agent) inserted.push(pending.id) + }) + ctx.on('agent/inbox/claimed', ({ agent: subject, message: pending, turn: ownerTurn }) => { + if (subject === agent) claimed.push({ id: pending.id, turn: ownerTurn }) + }) + + agent.inbox.append('next-turn', turn) + agent.inbox.append('next-step', step) + + expect(inserted).toEqual([turn.id, step.id]) + expect(() => { agent.inbox.append('next-step', turn) }).toThrow(`message "${turn.id}" is already pending`) + const invalid = Session.create(SessionId('invalid-persisted-inbox'), [{ + type: 'agent/inbox/spliced', + seq: SessionSeq(0), + time: 1, + data: { target: 'next-turn', start: 99, inserted: [] }, + }]) + expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox')) + .toThrow(/invalid persisted inbox splice/) + expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn]) + expect(claimed).toEqual([ + { id: step.id, turn: 3 }, + { id: turn.id, turn: 3 }, + ]) + expect(agent.session.snapshotEvents().map(event => event.type)).toEqual([ + 'agent/inbox/spliced', + 'agent/inbox/spliced', + 'agent/inbox/spliced', + 'agent/inbox/spliced', + ]) await ctx.fiber.dispose() }) diff --git a/packages/test-support/agent-loop-testkit/tsconfig.json b/packages/test-support/agent-loop-testkit/tsconfig.json index 5e5b3c47f2..1a07c30bd3 100644 --- a/packages/test-support/agent-loop-testkit/tsconfig.json +++ b/packages/test-support/agent-loop-testkit/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-loop" + }, { "path": "../../llm/llm" }, @@ -28,6 +31,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../session/session-projection" } ] } diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 696a1ef99e..3384372a65 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -5,7 +5,6 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -18,7 +17,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent async function harness(adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index d25d02bba9..b99909fa2a 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -11,12 +11,13 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' let root: string | undefined let context: Context | undefined @@ -33,7 +34,7 @@ function agent(ctx: Context): Agent { const id = SessionId('todo-loader-agent') const session = Session.create(id) const value: Agent = { - id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + id, options: {}, session, inbox: unsupportedInbox(), status: 'idle', ctx: scope.ctx, followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, runMaintenance: task => task(new AbortController().signal), diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index 6d14949aba..6293e0ff55 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -5,7 +5,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -21,7 +20,6 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { const ctx = new Context() const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -59,7 +57,6 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport), ]) await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts index ed67dccc0a..06bbdd277e 100644 --- a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts @@ -7,7 +7,6 @@ import InvariantRegistry from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver' @@ -36,7 +35,6 @@ async function setup(script: Script) { const adapter = new MockAdapter(script) await mountAgentLoopTestDependencies(ctx) await mountInvariants(ctx) - await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts index daf7516ca2..d9a118c7bb 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts @@ -1,13 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process' @@ -31,12 +27,7 @@ afterEach(async () => { async function harness(): Promise { const built = new Context() - await built.plugin(LlmRuntime) - await built.plugin(SessionStore) - await built.plugin(SessionProjectionRegistry) - await built.plugin(SystemPrompt) - await built.plugin(ToolRuntime) - await built.plugin(AgentRegistry) + await mountAgentLoopTestDependencies(built) await built.plugin(AgentLoop, { agents: [] }) await built.plugin(LlmDeepSeek) await built.plugin(SubagentRuntime) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad7442d695..fa5cbd9ef1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,7 +57,7 @@ importers: version: 4.1.8(vitest@4.1.8) '@yao-pkg/pkg': specifier: 6.21.0 - version: 6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8) + version: 6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8)(supports-color@9.4.0) '@yarnpkg/cli-dist': specifier: 4.17.1 version: 4.17.1 @@ -90,7 +90,7 @@ importers: version: 1.32.0 mdast-util-from-markdown: specifier: ^2.0.3 - version: 2.0.3 + version: 2.0.3(supports-color@9.4.0) mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 @@ -126,7 +126,7 @@ importers: version: 6.0.3 vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.20.0)(@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@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -356,6 +356,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../packages/test-support/agent-loop-testkit '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../packages/attachment/attachment-local @@ -477,6 +483,103 @@ importers: specifier: 8.21.0 version: 8.21.0 + apps/desktop: + dependencies: + electron-updater: + specifier: ^6.8.9 + version: 6.8.9 + semver: + specifier: ^7.8.5 + version: 7.8.5 + devDependencies: + '@aws-sdk/client-s3': + specifier: 3.1067.0 + version: 3.1067.0 + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../packages/util/home-paths + '@electron/notarize': + specifier: 2.5.0 + version: 2.5.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.20.0 + version: 22.20.0 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 + app-builder-lib: + specifier: 26.15.3 + version: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + electron: + specifier: ^44.0.0 + version: 44.0.0(supports-color@9.4.0) + electron-builder: + specifier: ^26.15.3 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + extract-zip: + specifier: ^2.0.1 + version: 2.0.1(supports-color@9.4.0) + 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 + tar: + specifier: ^7.5.0 + version: 7.5.22 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + apps/desktop-host: + dependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../vendor/include + '@deepseek-ai/dsh': + specifier: workspace:^ + version: link:../cli + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../packages/api/gateway + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../packages/boot/app-boot + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../packages/client/connection + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../packages/client/modules + '@deepseek-ai/dsh-client-ui-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/client/ui-directory-picker-native + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-native + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../packages/host/webserver + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment + '@deepseek-ai/dsh-web-frontend': + specifier: workspace:^ + version: link:../web + apps/web: devDependencies: '@deepseek-ai/cordis-plugin-group': @@ -523,13 +626,13 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.7.0(supports-color@9.4.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) fflate: specifier: ^0.8.2 version: 0.8.3 http-server: specifier: ^14.1.1 - version: 14.1.1 + version: 14.1.1(supports-color@9.4.0) playwright: specifier: ^1.49.0 version: 1.61.1 @@ -865,6 +968,12 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets @@ -1424,6 +1533,12 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -3001,7 +3116,7 @@ importers: version: 0.16.47 mdast-util-from-markdown: specifier: ^2.0.3 - version: 2.0.3 + version: 2.0.3(supports-color@9.4.0) mdast-util-gfm: specifier: ^3.1.0 version: 3.1.0 @@ -4207,6 +4322,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs @@ -4421,6 +4539,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4466,6 +4587,9 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values packages/core/agent-default-model: dependencies: @@ -4736,6 +4860,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot @@ -5347,6 +5474,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id @@ -5583,6 +5713,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -5628,6 +5761,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -5659,6 +5798,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -5750,6 +5895,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../goal @@ -6066,7 +6214,7 @@ importers: version: link:../../../vendor/schemastery compression: specifier: ^1.8.1 - version: 1.8.1 + version: 1.8.1(supports-color@9.4.0) negotiator: specifier: ^1.0.0 version: 1.0.0 @@ -6273,6 +6421,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -8111,6 +8262,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8227,6 +8381,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8329,6 +8486,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9150,6 +9310,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -9178,6 +9341,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -9215,6 +9381,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-jobs': specifier: workspace:^ version: link:../../jobs/jobs @@ -9275,6 +9444,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -10472,6 +10644,9 @@ importers: '@deepseek-ai/dsh-util-time': specifier: workspace:^ version: link:../../packages/util/time + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../packages/util/values '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../../packages/web/web @@ -10633,7 +10808,7 @@ importers: version: 1.11.21 debug: specifier: 4.4.3 - version: 4.4.3 + version: 4.4.3(supports-color@9.4.0) mermaid: specifier: 11.16.0 version: 11.16.0 @@ -10822,6 +10997,9 @@ packages: resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -10835,14 +11013,26 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@aws-sdk/checksums@3.1000.29': + resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1048.0': resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1067.0': + resolution: {integrity: sha512-3f64o9YWzwJ9WzMIC4JlUQiMOm7R/EtkIDyFdj8yaQXuh8SR9ezz2R32UMpvTlVMtpoPan3Uj8oveAHr2UeExw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.20': resolution: {integrity: sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.977.9': + resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.46': resolution: {integrity: sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==} engines: {node: '>=20.0.0'} @@ -10883,6 +11073,14 @@ packages: resolution: {integrity: sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.54': + resolution: {integrity: sha512-cDplgLpXZy7MfREXbAeOm8PFT8ibjD5B5rbqxocFR/5rdSbXUPURIAvRToVqjcOR5gHYbEkndKAs+Zw7FfZj1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.75': + resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-websocket@3.972.28': resolution: {integrity: sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw==} engines: {node: '>= 14.0.0'} @@ -10895,6 +11093,10 @@ packages: resolution: {integrity: sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1048.0': resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} engines: {node: '>=20.0.0'} @@ -10907,6 +11109,10 @@ packages: resolution: {integrity: sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.7': resolution: {integrity: sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==} engines: {node: '>=20.0.0'} @@ -10915,10 +11121,18 @@ packages: resolution: {integrity: sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -11126,6 +11340,50 @@ packages: resolution: {integrity: sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==} engines: {node: '>=22.19.0'} + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.2.0': + resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -12044,6 +12302,14 @@ packages: typescript: optional: true + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + '@mermaid-js/mermaid-mindmap@9.3.0': resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} @@ -12071,12 +12337,46 @@ 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: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@noble/hashes@2.3.0': resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} @@ -12479,6 +12779,21 @@ packages: cpu: [x64] os: [win32] + '@peculiar/asn1-schema@2.9.4': + resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==} + engines: {node: '>=14'} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -12913,6 +13228,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -12921,6 +13240,10 @@ packages: resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.3.9': resolution: {integrity: sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==} engines: {node: '>=18.0.0'} @@ -12945,10 +13268,18 @@ packages: resolution: {integrity: sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.14.4': resolution: {integrity: sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==} engines: {node: '>=18.0.0'} + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -12969,6 +13300,10 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + '@tanstack/react-virtual@3.14.9': resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==} peerDependencies: @@ -13021,6 +13356,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -13144,12 +13482,18 @@ packages: '@types/fs-ext@2.0.3': resolution: {integrity: sha512-0j2F+laosJF2NTd2DVheQ5GvXo8ln9L175VwLPfbsppE33iYC+6gn6XlOQS0pGvZm2yrQ32/LRZh0As/7rCs2Q==} + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -13168,6 +13512,9 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -13189,6 +13536,9 @@ packages: '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} @@ -13218,9 +13568,15 @@ packages: '@types/readable-stream@4.0.24': resolution: {integrity: sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==} + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -13254,6 +13610,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -13466,6 +13825,10 @@ packages: '@vueuse/shared@12.8.2': resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + '@xterm/headless@6.0.0': resolution: {integrity: sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==} @@ -13487,6 +13850,10 @@ packages: resolution: {integrity: sha512-WoxUM/Be4hfsX06FxsvpGgfYqwgivMV7/Ol7aFuSfSmY6rRaiju4QxOEe9RUS0iYcSHWl5i9AhB1cMoE0p+XiA==} engines: {node: '>=18.12.0'} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -13557,12 +13924,23 @@ packages: anynum@1.0.0: resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -13574,9 +13952,23 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -13609,8 +14001,8 @@ packages: bare-buffer: optional: true - bare-path@3.1.1: - resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + bare-path@3.1.2: + resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==} bare-stream@2.13.4: resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} @@ -13626,8 +14018,8 @@ packages: bare-events: optional: true - bare-url@2.5.2: - resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + bare-url@2.5.3: + resolution: {integrity: sha512-3absfEzoyFosWT8v83ZcJgbTJxv+S/sI7jBfeCMlUVatSaefRmwweqBhFpMllQv+HTxs/FbbToVOw1c05NORkQ==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -13663,9 +14055,16 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@2.1.2: resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} @@ -13678,9 +14077,15 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} @@ -13691,6 +14096,14 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} + builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -13703,10 +14116,22 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -13757,9 +14182,27 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -13771,6 +14214,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -13778,6 +14225,10 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -13790,6 +14241,10 @@ packages: resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} engines: {node: ^12.20.0 || >=14} + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} @@ -13801,6 +14256,9 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -13845,6 +14303,9 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -14065,16 +14526,32 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -14087,6 +14564,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -14098,6 +14578,12 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} + dockerfile-ast@0.7.1: resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} @@ -14107,6 +14593,14 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -14136,9 +14630,37 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} + + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} + electron-to-chromium@1.5.393: resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@44.0.0: + resolution: {integrity: sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==} + engines: {node: '>= 22.12.0'} + hasBin: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -14167,6 +14689,17 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -14182,9 +14715,16 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -14314,6 +14854,9 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -14327,6 +14870,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-check@4.8.0: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} @@ -14356,6 +14904,9 @@ packages: resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} hasBin: true + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -14380,6 +14931,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -14411,6 +14965,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -14430,10 +14988,33 @@ packages: resolution: {integrity: sha512-/TrISPOFhCkbgIRWK9lzscRzwPCu0PqtCcvMc9jsHKBgZGoqA0VzhspVht5Zu8lxaXjIYIBWILHpRotYkCCcQA==} engines: {node: '>= 8.0.0'} + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fs-extra@11.3.1: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -14474,6 +15055,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@9.0.1: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} @@ -14500,10 +15085,22 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + globals@17.7.0: resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -14519,6 +15116,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -14533,10 +15134,17 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -14561,6 +15169,10 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + html-encoding-sniffer@3.0.0: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} @@ -14575,6 +15187,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -14592,6 +15207,10 @@ packages: engines: {node: '>=12'} hasBin: true + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -14632,6 +15251,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -14720,9 +15343,25 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -14742,6 +15381,11 @@ packages: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -14837,11 +15481,17 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} @@ -14877,6 +15527,9 @@ packages: layout-base@2.0.1: resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lefthook-darwin-arm64@2.1.9: resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==} cpu: [arm64] @@ -15027,9 +15680,19 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -15040,6 +15703,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -15050,6 +15717,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -15075,6 +15746,10 @@ packages: engines: {node: '>= 20'} hasBin: true + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -15219,10 +15894,18 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -15232,6 +15915,15 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -15240,6 +15932,13 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} @@ -15264,6 +15963,10 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -15274,6 +15977,13 @@ 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==} @@ -15303,6 +16013,10 @@ packages: resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} engines: {node: '>=10'} + node-abi@4.34.0: + resolution: {integrity: sha512-4Oy5Q6/Ftna9sXyrkdnKypfvm9uWRpxUPvlw4oA192QNMN39aq8k4l36TUUUU/ONw7ivGVi402Ud+UBPVDYh6A==} + engines: {node: '>=22.12.0'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -15358,6 +16072,9 @@ packages: resolution: {integrity: sha512-yuXz43GmtQyMrO75u2Z8KZAafMhnMH8RTOZBJWGDU9HoD2QxT6q4PF28iLNm/OS9BkS8MHwCKpgTk3d6qW584A==} engines: {node: '>=20'} + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -15367,6 +16084,15 @@ 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} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -15380,6 +16106,15 @@ packages: non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -15392,6 +16127,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -15465,6 +16204,10 @@ packages: vite-plus: optional: true + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -15511,6 +16254,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -15536,6 +16283,13 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -15550,6 +16304,10 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} @@ -15563,6 +16321,15 @@ packages: engines: {node: '>=18'} hasBin: true + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + pnpm@11.7.0: + resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==} + engines: {node: '>=22.13'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -15612,6 +16379,10 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -15623,6 +16394,13 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -15649,6 +16427,13 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -15656,6 +16441,10 @@ packages: quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -15684,6 +16473,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -15731,6 +16524,13 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -15743,6 +16543,13 @@ packages: engines: {node: '>= 0.4'} hasBin: true + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -15750,6 +16557,15 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -15814,6 +16630,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -15831,10 +16654,22 @@ packages: secure-compare@3.0.1: resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -15849,6 +16684,10 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -15902,6 +16741,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -15912,6 +16754,10 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + smol-toml@1.7.1: resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} @@ -15920,6 +16766,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -15936,9 +16789,16 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + 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==} @@ -15994,6 +16854,10 @@ packages: stylis@4.4.0: resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -16036,9 +16900,22 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -16061,6 +16938,13 @@ packages: resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} hasBin: true + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -16080,6 +16964,9 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -16157,6 +17044,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -16180,12 +17071,19 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + undici@7.28.0: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} @@ -16220,6 +17118,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -16248,6 +17150,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -16476,6 +17381,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -16507,6 +17415,16 @@ packages: engines: {node: '>= 8'} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -16551,6 +17469,10 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -16561,6 +17483,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -16574,10 +17499,21 @@ packages: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs@16.2.2: resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -16813,6 +17749,15 @@ snapshots: '@aws-sdk/types': 3.973.12 tslib: 2.8.1 + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.12 + '@aws-sdk/util-locate-window': 3.965.7 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -16839,6 +17784,14 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 + '@aws-sdk/checksums@3.1000.29': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/client-bedrock-runtime@3.1048.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -16856,6 +17809,23 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/client-s3@3.1067.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.20 + '@aws-sdk/credential-provider-node': 3.972.55 + '@aws-sdk/middleware-flexible-checksums': 3.974.54 + '@aws-sdk/middleware-sdk-s3': 3.972.75 + '@aws-sdk/signature-v4-multi-region': 3.996.34 + '@aws-sdk/types': 3.973.12 + '@smithy/core': 3.24.7 + '@smithy/fetch-http-handler': 5.4.7 + '@smithy/node-http-handler': 4.7.8 + '@smithy/types': 4.14.4 + tslib: 2.8.1 + '@aws-sdk/core@3.974.20': dependencies: '@aws-sdk/types': 3.973.12 @@ -16867,6 +17837,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.977.9': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.46': dependencies: '@aws-sdk/core': 3.974.20 @@ -16965,6 +17946,20 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/middleware-flexible-checksums@3.974.54': + dependencies: + '@aws-sdk/checksums': 3.1000.29 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.28': dependencies: '@aws-sdk/core': 3.974.20 @@ -16995,6 +17990,13 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1048.0': dependencies: '@aws-sdk/core': 3.974.20 @@ -17018,6 +18020,11 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@aws-sdk/types@3.974.5': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws-sdk/util-locate-window@3.965.7': dependencies: tslib: 2.8.1 @@ -17028,8 +18035,15 @@ snapshots: fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -17038,20 +18052,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@9.4.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -17087,17 +18101,17 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -17126,14 +18140,14 @@ snapshots: dependencies: '@babel/types': 8.0.0-rc.6 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@9.4.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@9.4.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -17144,7 +18158,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@9.4.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -17152,7 +18166,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -17264,6 +18278,100 @@ snapshots: '@earendil-works/pi-telemetry@0.85.1': {} + '@electron-internal/extract-zip@1.0.5': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@5.1.0(supports-color@9.4.0)': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.5 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.28.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.0': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@9.4.0) + node-abi: 4.34.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@9.4.0) + dir-compare: 4.2.0 + fs-extra: 11.4.0 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 11.4.0 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -17536,7 +18644,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -17897,6 +19005,19 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + '@mermaid-js/mermaid-mindmap@9.3.0': dependencies: '@braintree/sanitize-url': 6.0.4 @@ -17958,6 +19079,24 @@ 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 @@ -17972,6 +19111,8 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@noble/hashes@1.4.0': {} + '@noble/hashes@2.3.0': {} '@nodable/entities@2.2.0': {} @@ -18239,6 +19380,28 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.76.0': optional: true + '@peculiar/asn1-schema@2.9.4': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.9.4 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + '@pkgjs/parseargs@0.11.0': optional: true @@ -18529,6 +19692,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@smithy/core@3.24.7': @@ -18537,6 +19702,11 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.3.9': dependencies: '@smithy/core': 3.24.7 @@ -18571,10 +19741,20 @@ snapshots: '@smithy/types': 4.14.4 tslib: 2.8.1 + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + '@smithy/types@4.14.4': dependencies: tslib: 2.8.1 + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -18599,6 +19779,10 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + '@tanstack/react-virtual@3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.17.7 @@ -18663,6 +19847,13 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.20.0 + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.20.0 + '@types/responselike': 1.0.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -18821,12 +20012,18 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.20.0 + '@types/geojson@7946.0.16': {} '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} '@types/js-yaml@4.0.9': {} @@ -18844,6 +20041,10 @@ snapshots: '@types/katex@0.16.8': {} + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.20.0 + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -18865,6 +20066,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 @@ -18894,8 +20099,14 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.20.0 + '@types/retry@0.12.0': {} + '@types/semver@7.8.0': {} + '@types/send@1.2.1': dependencies: '@types/node': 22.20.0 @@ -18925,6 +20136,11 @@ snapshots: dependencies: '@types/node': 22.20.0 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.20.0 + optional: true + '@typescript-eslint/types@8.61.0': {} '@ungap/structured-clone@1.3.3': {} @@ -18934,11 +20150,11 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitejs/plugin-react@4.7.0(supports-color@9.4.0)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@9.4.0) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@9.4.0)) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -19172,6 +20388,8 @@ snapshots: transitivePeerDependencies: - typescript + '@xmldom/xmldom@0.8.15': {} + '@xterm/headless@6.0.0': {} '@yao-pkg/pkg-fetch@3.6.4': @@ -19187,11 +20405,11 @@ snapshots: - bare-buffer - react-native-b4a - '@yao-pkg/pkg@6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8)': + '@yao-pkg/pkg@6.21.0(patch_hash=28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8)(supports-color@9.4.0)': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@9.4.0) '@babel/types': 7.29.7 '@roberts_lando/vfs': 0.3.3 '@yao-pkg/pkg-fetch': 3.6.4 @@ -19221,6 +20439,8 @@ snapshots: js-yaml: 4.3.1 tslib: 2.8.1 + abbrev@4.0.0: {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -19291,12 +20511,66 @@ snapshots: anynum@1.0.0: {} + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.2.0 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.3.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 + async-exit-hook: 2.0.1 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3(supports-color@9.4.0) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.3.1 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + pkijs: 3.4.0 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.22 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + argparse@2.0.1: {} aria-query@5.3.0: dependencies: dequal: 2.0.3 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -19311,8 +20585,16 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + async-exit-hook@2.0.1: {} + async@3.2.6: {} + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + aws4@1.13.2: {} + b4a@1.8.1: {} balanced-match@1.0.2: {} @@ -19324,15 +20606,15 @@ snapshots: bare-fs@4.8.1: dependencies: bare-events: 2.9.2 - bare-path: 3.1.1 + bare-path: 3.1.2 bare-stream: 2.13.4(bare-events@2.9.2) - bare-url: 2.5.2 + bare-url: 2.5.3 fast-fifo: 1.3.2 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - bare-path@3.1.1: {} + bare-path@3.1.2: {} bare-stream@2.13.4(bare-events@2.9.2): dependencies: @@ -19344,9 +20626,9 @@ snapshots: transitivePeerDependencies: - react-native-b4a - bare-url@2.5.2: + bare-url@2.5.3: dependencies: - bare-path: 3.1.1 + bare-path: 3.1.2 base64-js@1.5.1: {} @@ -19378,7 +20660,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -19388,8 +20670,16 @@ snapshots: transitivePeerDependencies: - supports-color + boolean@3.2.0: + optional: true + bowser@2.14.1: {} + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 @@ -19406,8 +20696,12 @@ snapshots: node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + buffer-image-size@0.6.4: dependencies: '@types/node': 22.20.0 @@ -19422,6 +20716,32 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3(supports-color@9.4.0) + sax: 1.6.1 + transitivePeerDependencies: + - supports-color + + builder-util@26.15.3: + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.3.1 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + builtin-modules@3.3.0: {} bundle-name@4.1.0: @@ -19430,8 +20750,22 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + cac@7.0.0: {} + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -19473,12 +20807,28 @@ snapshots: chownr@3.0.0: {} + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + clsx@2.1.1: {} color-convert@2.0.1: @@ -19487,27 +20837,35 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@15.0.0: {} + commander@5.1.0: {} + commander@7.2.0: {} commander@8.3.0: {} commander@9.5.0: {} + compare-version@0.1.2: {} + compare-versions@6.1.1: {} compressible@2.0.18: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@9.4.0): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@9.4.0) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -19515,6 +20873,8 @@ snapshots: transitivePeerDependencies: - supports-color + concat-map@0.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -19548,6 +20908,9 @@ snapshots: dependencies: layout-base: 2.0.1 + cross-dirname@0.1.0: + optional: true + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -19756,13 +21119,17 @@ snapshots: dayjs@1.11.21: {} - debug@2.6.9: + debug@2.6.9(supports-color@9.4.0): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 9.4.0 - debug@4.4.3: + debug@4.4.3(supports-color@9.4.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 9.4.0 decimal.js@10.6.0: {} @@ -19785,20 +21152,41 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + defu@6.1.7: {} delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dequal@2.0.3: {} detect-libc@2.1.2: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -19807,6 +21195,21 @@ snapshots: diff@9.0.0: {} + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + fs-extra: 10.1.0 + js-yaml: 4.3.1 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + dockerfile-ast@0.7.1: dependencies: vscode-languageserver-textdocument: 1.0.12 @@ -19818,6 +21221,12 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + dts-resolver@3.0.0(oxc-resolver@11.20.0): optionalDependencies: oxc-resolver: 11.20.0 @@ -19854,8 +21263,84 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): + dependencies: + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.15.3: + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 + chalk: 4.1.2 + form-data: 4.0.6 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + electron-to-chromium@1.5.393: {} + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.3.1 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3(supports-color@9.4.0) + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@44.0.0(supports-color@9.4.0): + dependencies: + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0(supports-color@9.4.0) + '@types/node': 24.13.3 + transitivePeerDependencies: + - supports-color + emoji-regex-xs@1.0.0: {} emoji-regex@8.0.0: {} @@ -19874,6 +21359,12 @@ snapshots: entities@8.0.0: {} + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + err-code@2.0.3: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -19884,8 +21375,18 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es-toolkit@1.49.0: {} + es6-error@4.1.1: + optional: true + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -20022,7 +21523,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -20115,6 +21616,8 @@ snapshots: expect-type@1.3.0: {} + exponential-backoff@3.1.3: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -20128,7 +21631,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -20155,6 +21658,16 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1(supports-color@9.4.0): + dependencies: + debug: 4.4.3(supports-color@9.4.0) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-check@4.8.0: dependencies: pure-rand: 8.4.0 @@ -20183,6 +21696,10 @@ snapshots: path-expression-matcher: 1.5.0 strnum: 2.4.0 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -20202,9 +21719,13 @@ snapshots: dependencies: flat-cache: 4.0.1 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20236,6 +21757,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -20250,12 +21779,45 @@ snapshots: dependencies: nan: 2.28.0 + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -20304,6 +21866,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@9.0.1: dependencies: '@sec-ant/readable-stream': 0.4.1 @@ -20337,8 +21903,33 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.5 + serialize-error: 7.0.1 + optional: true + globals@17.7.0: {} + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + globrex@0.1.2: {} google-auth-library@10.7.0: @@ -20356,6 +21947,20 @@ snapshots: gopd@1.2.0: {} + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + graceful-fs@4.2.11: {} hachure-fill@0.5.2: {} @@ -20375,8 +21980,17 @@ snapshots: has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -20407,6 +22021,10 @@ snapshots: hookable@6.1.1: {} + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + html-encoding-sniffer@3.0.0: dependencies: whatwg-encoding: 2.0.0 @@ -20421,6 +22039,8 @@ snapshots: html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -20432,7 +22052,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -20444,7 +22064,7 @@ snapshots: transitivePeerDependencies: - debug - http-server@14.1.1: + http-server@14.1.1(supports-color@9.4.0): dependencies: basic-auth: 2.0.1 chalk: 4.1.2 @@ -20455,7 +22075,7 @@ snapshots: mime: 1.6.0 minimist: 1.2.8 opener: 1.5.2 - portfinder: 1.0.38 + portfinder: 1.0.38(supports-color@9.4.0) secure-compare: 3.0.1 union: 0.5.0 url-join: 4.0.1 @@ -20463,10 +22083,15 @@ snapshots: - debug - supports-color + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -20494,6 +22119,11 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -20548,8 +22178,16 @@ snapshots: isarray@1.0.0: {} + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + isexe@2.0.0: {} + isexe@3.1.5: {} + + isexe@4.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -20573,8 +22211,13 @@ snapshots: dependencies: '@isaacs/cliui': 9.0.0 - jiti@2.7.0: - optional: true + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@2.7.0: {} jose@6.2.3: {} @@ -20664,8 +22307,15 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: + optional: true + json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + jsonfile@6.2.1: dependencies: universalify: 2.0.1 @@ -20724,6 +22374,8 @@ snapshots: layout-base@2.0.1: {} + lazy-val@1.0.5: {} + lefthook-darwin-arm64@2.1.9: optional: true @@ -20837,8 +22489,14 @@ snapshots: lodash-es@4.18.1: {} + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash.merge@4.6.2: {} + lodash@4.18.1: {} + long@5.3.2: {} longest-streak@3.1.0: {} @@ -20847,6 +22505,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lowercase-keys@2.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.5.1: {} @@ -20855,6 +22515,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -20869,7 +22533,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 mark.js@8.11.1: {} @@ -20877,6 +22541,11 @@ snapshots: marked@16.4.2: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -20886,14 +22555,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@9.4.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@9.4.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -20915,7 +22584,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: @@ -20924,7 +22593,7 @@ snapshots: mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -20934,7 +22603,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -20943,14 +22612,14 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 @@ -20966,7 +22635,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@9.4.0) mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: @@ -21214,10 +22883,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@9.4.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -21236,20 +22905,38 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 mime@1.6.0: {} + mime@2.6.0: {} + + mimic-response@1.0.1: {} + mimic-response@3.1.0: {} minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.2 + minimatch@9.0.9: dependencies: brace-expansion: 2.1.2 @@ -21268,12 +22955,32 @@ snapshots: mkdirp-classic@0.5.3: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mri@1.2.0: {} ms@2.0.0: {} 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 @@ -21295,6 +23002,10 @@ snapshots: dependencies: semver: 7.8.5 + node-abi@4.34.0: + dependencies: + semver: 7.8.5 + node-addon-api@7.1.1: {} node-addon-native-custom-loader@0.1.4: {} @@ -21346,6 +23057,10 @@ snapshots: node-addon-require-builtin-win32-ia32-msvc: 0.1.4 node-addon-require-builtin-win32-x64-msvc: 0.1.4 + node-api-version@0.2.1: + dependencies: + semver: 7.8.5 + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -21354,6 +23069,24 @@ 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 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.8.5 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.28.0 + which: 6.0.1 + node-int64@0.4.0: {} node-pty@1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0): @@ -21365,6 +23098,12 @@ snapshots: non-layered-tidy-tree-layout@2.0.2: optional: true + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-url@6.1.0: {} + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -21374,6 +23113,9 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: + optional: true + obug@2.1.3: {} on-finished@2.4.1: @@ -21486,6 +23228,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.76.0 oxlint-tsgolint: 7.0.2001 + p-cancelable@2.1.1: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -21521,6 +23265,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -21541,6 +23287,10 @@ snapshots: pathe@2.0.3: {} + pe-library@0.4.1: {} + + pend@1.2.0: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -21549,6 +23299,15 @@ snapshots: pkce-challenge@5.0.1: {} + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + platform@1.3.6: {} playwright-core@1.61.1: {} @@ -21559,6 +23318,14 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pnpm@11.7.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -21566,10 +23333,10 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - portfinder@1.0.38: + portfinder@1.0.38(supports-color@9.4.0): dependencies: async: 3.2.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) transitivePeerDependencies: - supports-color @@ -21614,12 +23381,25 @@ snapshots: dependencies: parse-ms: 4.0.0 + proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} process@0.11.10: {} progress@2.0.3: {} + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} protobufjs@7.6.4: @@ -21657,6 +23437,12 @@ snapshots: pure-rand@8.4.0: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -21664,6 +23450,8 @@ snapshots: quansync@1.0.0: {} + quick-lru@5.1.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -21694,6 +23482,12 @@ snapshots: dependencies: loose-envify: 1.4.0 + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -21747,6 +23541,12 @@ snapshots: requires-port@1.0.0: {} + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + resolve-pkg-maps@1.0.0: {} resolve.exports@2.0.3: {} @@ -21758,10 +23558,30 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + retry@0.13.1: {} rfdc@1.4.1: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + robust-predicates@3.0.3: {} rolldown-plugin-dts@0.25.2(oxc-resolver@11.20.0)(rolldown@1.1.1)(typescript@6.0.3): @@ -21862,7 +23682,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -21884,6 +23704,12 @@ snapshots: safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.1: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -21902,15 +23728,22 @@ snapshots: secure-compare@3.0.1: {} + semver-compare@1.0.0: + optional: true + + semver@5.7.2: {} + semver@6.3.1: {} + semver@7.7.4: {} + semver@7.8.4: {} semver@7.8.5: {} send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -21924,6 +23757,11 @@ snapshots: transitivePeerDependencies: - supports-color + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -22028,6 +23866,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: {} @@ -22038,10 +23878,21 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + smol-toml@1.7.1: {} source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + space-separated-tokens@2.0.2: {} spdx-exceptions@2.5.0: {} @@ -22055,8 +23906,13 @@ snapshots: speakingurl@14.0.1: {} + sprintf-js@1.1.3: + optional: true + stackback@0.0.2: {} + stat-mode@1.0.0: {} + standardwebhooks@1.1.1: dependencies: '@stablelib/base64': 1.0.1 @@ -22122,6 +23978,12 @@ snapshots: stylis@4.4.0: {} + sumchecker@3.0.1: + dependencies: + debug: 4.4.3(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -22151,7 +24013,7 @@ snapshots: tar-stream: 3.2.1 optionalDependencies: bare-fs: 4.8.1 - bare-path: 3.1.1 + bare-path: 3.1.2 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -22191,12 +24053,28 @@ snapshots: - bare-abort-controller - react-native-b4a + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + text-decoder@1.2.7: dependencies: b4a: 1.8.1 transitivePeerDependencies: - react-native-b4a + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + + tiny-typed-emitter@2.1.0: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -22214,6 +24092,12 @@ snapshots: dependencies: tldts-core: 7.4.5 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + toidentifier@1.0.1: {} tough-cookie@6.0.1: @@ -22228,6 +24112,10 @@ snapshots: trim-lines@3.0.1: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -22287,6 +24175,9 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@0.13.1: + optional: true + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -22309,10 +24200,14 @@ snapshots: undici-types@6.21.0: {} + undici-types@7.18.2: {} + undici-types@7.24.6: {} undici-types@8.3.0: {} + undici@6.28.0: {} + undici@7.28.0: {} undici@8.10.0: {} @@ -22351,6 +24246,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -22379,6 +24276,8 @@ snapshots: dependencies: react: 18.3.1 + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} uuid@14.0.1: {} @@ -22395,9 +24294,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(supports-color@9.4.0)(typescript@6.0.3)(vite@8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@9.4.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.3) vite: 8.0.16(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) @@ -22640,6 +24539,14 @@ snapshots: web-streams-polyfill@3.3.3: {} + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.9.4 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + webidl-conversions@8.0.1: {} whatwg-encoding@2.0.0: @@ -22664,6 +24571,14 @@ snapshots: dependencies: isexe: 2.0.0 + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -22696,18 +24611,24 @@ snapshots: xml-naming@0.1.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} y18n@5.0.8: {} yallist@3.1.1: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yaml@2.9.0: {} yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.2: dependencies: cliui: 7.0.4 @@ -22718,6 +24639,21 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} yoctocolors@2.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ec80d70d0..7cb1746e11 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,6 +50,12 @@ allowBuilds: # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true + # electron-builder pulls in the optional Squirrel.Windows helper, whose + # install script only selects its bundled 7-Zip executable. Desktop ships + # Windows through NSIS, so that mutation is not part of our build. + electron-winstaller: false + # Store-index rewriting only needs msgpackr's portable JavaScript codec. + msgpackr-extract: false minimumReleaseAgeExclude: # Fresh pi-ai releases carry the model catalog updates that are the whole diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 97c1a6bc68..d34b14bec4 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -121,6 +121,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 227e757d0c..ca8bc07bcb 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -53,10 +53,16 @@ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ /** npm namespace reserved for private experimental packages. */ const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-' /** Directories whose packages this repository publishes: one release member each. */ -const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ +const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/ +/** Installable application assembled by electron-builder rather than published to npm. */ +const desktopApplicationDirectory = 'apps/desktop' const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { '@deepseek-ai/dsh': ['lib/*.js'], + '@deepseek-ai/dsh-desktop-host': [ + 'lib/index.js', + 'config/desktop.cordis.patch.yml', + ], // Sourcemaps stay out by payload policy; the worker-preview surface // (dist/preview.html and dist/preview/) backs private experimental // packages and is not published. @@ -359,7 +365,7 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st } } - if (dir.startsWith('apps/') && manifest.name?.startsWith('@deepseek-ai/')) { + if (dir.startsWith('apps/') && dir !== desktopApplicationDirectory && manifest.name?.startsWith('@deepseek-ai/')) { const expectedFiles = appPackageFiles[manifest.name] if (expectedFiles === undefined) { errors.push(`${label}: app package has no publication files policy`) diff --git a/scripts/clean.ts b/scripts/clean.ts index c0f7d71763..be13a95026 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -68,6 +68,7 @@ export class RepositoryCleaner { const canonicalRoot = await realpath(this.root) await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot) + await this.addIfPresent(targets, join(this.root, 'apps/desktop/.desktop-build'), canonicalRoot) // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 70677a8ae6..2300ddf7b3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -253,6 +253,7 @@ export const LINK_MAP: Readonly> = { ContentBlock: 'llm-streaming.md', CreateAgentOptions: 'core.md', GenerateOptions: 'llm-streaming.md', + Inbox: 'core.md', InboxItem: 'core.md', InboxPlacement: 'core.md', InspectorJsonValue: 'extensions.md', @@ -670,6 +671,9 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Promise', 'Record', 'Readonly', + 'ReadonlyMap', + 'Request', + 'Response', 'Uint8Array', ]) diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 69f6582838..146580105a 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -48,7 +48,7 @@ interface PlannedVersion { readonly tag: string | undefined } -/** One private dsh package whose version follows the publishable family. */ +/** One private dsh workspace whose version follows the publishable family. */ interface PrivateDshVersion { /** Repository-relative manifest path. */ readonly manifestPath: string @@ -243,13 +243,13 @@ function rootVersion(root: string): string { } /** - * Discover private package manifests that share the dsh version without joining - * its publish set. + * Discover private package and application manifests that share the dsh version + * without joining its publish set. * @param root - repository root. - * @returns Private package manifests sorted by path. + * @returns Private workspace manifests sorted by path. */ function privateDshVersions(root: string): PrivateDshVersion[] { - return globSync('packages/*/*/package.json', { cwd: root }) + return globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: root }) .map(path => path.replaceAll('\\', '/')) .sort() .flatMap((manifestPath) => { diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 8d9fbbe3e8..64cd1e268e 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -49,10 +49,20 @@ describe('release families', () => { expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-agent-team') }) - it('bumps private dsh packages without adding release tags', () => { + it('excludes private applications from the publish set', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-release-private-')) + roots.push(root) + write(join(root, 'apps/public/package.json'), '{"name":"@deepseek-ai/dsh-public","version":"0.0.1"}\n') + write(join(root, 'apps/private/package.json'), '{"name":"@deepseek-ai/dsh-private","version":"0.0.1","private":true}\n') + + expect(releaseFamily('dsh').members(root).map(entry => entry.name)).toEqual(['@deepseek-ai/dsh-public']) + }) + + it('bumps private dsh workspaces without adding release tags', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-release-version-')) roots.push(root) write(join(root, 'package.json'), '{"version":"0.0.1"}\n') + write(join(root, 'apps/desktop/package.json'), '{"version":"0.0.1","private":true}\n') write(join(root, 'packages/experimental/prototype/package.json'), '{"version":"0.0.1","private":true}\n') write(join(root, 'packages/core/unselected/package.json'), '{"version":"0.0.1"}\n') @@ -63,6 +73,7 @@ describe('release families', () => { expect(planned.map(entry => ({ path: entry.manifestPath, tag: entry.tag }))).toEqual([ { path: 'package.json', tag: undefined }, { path: 'packages/core/published/package.json', tag: 'dsh-v0.0.2' }, + { path: 'apps/desktop/package.json', tag: undefined }, { path: 'packages/experimental/prototype/package.json', tag: undefined }, ]) }) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 6a888c9879..d1b5d23b82 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -117,7 +117,7 @@ export abstract class ReleaseFamily { /** * Discover this family's members. * @param root - repository root. - * @returns Members sorted by directory, with names validated and deduplicated. + * @returns Publishable members sorted by directory, with names validated and deduplicated. */ members(root: string): ReleaseMember[] { const manifestPaths = globSync([...this.patterns], { cwd: root }).sort() @@ -128,6 +128,7 @@ export abstract class ReleaseFamily { for (const manifestPath of manifestPaths) { const normalized = manifestPath.replaceAll('\\', '/') const manifest = readManifest(resolve(root, manifestPath)) + if (manifest.private === true) continue const name = requireString(manifest, 'name', normalized) const version = requireString(manifest, 'version', normalized) if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`) diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 0742eedcda..6f187e83af 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' +import { pnpmInvocation } from '../pnpm-invocation.ts' import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' import { isEntry, runConcurrent } from './process.ts' import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' @@ -25,7 +26,8 @@ const DEFAULT_OUTPUT = 'dist/npm' * @returns The tarball filename. */ async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise { - await runConcurrent('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) + const invocation = pnpmInvocation(['--dir', member.directory, 'pack', '--pack-destination', destination]) + await runConcurrent(invocation.command, invocation.args) const filename = tarballName(member) const tarball = join(destination, filename) diff --git a/scripts/release/tarball.ts b/scripts/release/tarball.ts index 568c24e877..b8a005e748 100644 --- a/scripts/release/tarball.ts +++ b/scripts/release/tarball.ts @@ -27,7 +27,7 @@ export interface PackedIdentity { * @returns Every path inside the archive. */ export function tarballFiles(tarball: string): string[] { - return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '') + return capture('tar', ['-tzf', tarball]).split(/\r?\n/u).filter(line => line !== '') } /** diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index f929c88512..c843a901ed 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -116,6 +116,11 @@ "symbol": "LlmCallConfigAdapterDefaults", "source": "packages/llm/llm/src/call-config.ts" }, + { + "doc": "docs/subsystems/core.md", + "symbol": "Inbox", + "source": "packages/core/agent/src/runtime-types.ts" + }, { "doc": "docs/subsystems/core.md", "symbol": "InboxTarget", diff --git a/scripts/verify-client-ui-i18n.spec.ts b/scripts/verify-client-ui-i18n.spec.ts index 4f4b45572d..ae02d25744 100644 --- a/scripts/verify-client-ui-i18n.spec.ts +++ b/scripts/verify-client-ui-i18n.spec.ts @@ -62,4 +62,20 @@ describe('Client UI i18n source check', () => { 'export const en = { title: "Hard-coded by design" }', )).toEqual([]) }) + + it('rejects Electron dialog, title, prompt, and DOM copy outside locale owners', () => { + const source = ` + dialog.showMessageBox({ title: 'Update available', message: 'Install it now?' }) + window.setTitle('Desktop plugins') + window.prompt('Target version') + status.textContent = 'Finished' + ` + expect(findUiI18nViolations('apps/desktop/src/main.ts', source).map(row => row.text)).toEqual([ + 'Update available', + 'Install it now?', + 'Desktop plugins', + 'Target version', + 'Finished', + ]) + }) }) diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts index fdb0962f04..ea09388a45 100644 --- a/scripts/verify-client-ui-i18n.ts +++ b/scripts/verify-client-ui-i18n.ts @@ -114,7 +114,7 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi sourceText, ts.ScriptTarget.Latest, true, - file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS, ) const violations = new Map() @@ -254,13 +254,27 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) ) collectExpression(node.expression, 'JSX child') - if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) { + if ((file.endsWith('.tsx') || file.startsWith('apps/desktop/')) && ts.isPropertyAssignment(node)) { const name = propertyName(node.name) if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { collectExpression(node.initializer, `${name} property`) } } + if (file.startsWith('apps/desktop/') && ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) + && (node.left.name.text === 'textContent' || node.left.name.text === 'innerText')) { + collectExpression(node.right, `${node.left.name.text} assignment`) + } + + if (file.startsWith('apps/desktop/') && ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && (node.expression.name.text === 'setTitle' || node.expression.name.text === 'prompt')) { + const copy = node.arguments[0] + if (copy !== undefined) collectExpression(copy, `${node.expression.name.text} argument`) + } + if (ts.isVariableDeclaration(node) && node.initializer !== undefined) { const name = propertyName(node.name) if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) { @@ -314,6 +328,8 @@ function sourceFiles(): string[] { ...[...clientComponentRoots].flatMap(clientRoot => globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })), ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }), + ...globSync('apps/desktop/src/{main,update-coordinator}.{ts,tsx}', { cwd: root }), + ...globSync('apps/desktop/renderer/*.js', { cwd: root }), ])] .map(file => file.replaceAll('\\', '/')) .filter(file => !file.endsWith('.d.ts')) diff --git a/tsconfig.host.json b/tsconfig.host.json index 4432540b06..7fef53ebf8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -99,6 +99,8 @@ "apps/web/tests/workflow-run.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", + "apps/desktop/scripts/**/*.ts", + "apps/desktop/tests/**/*.ts", "benchmarks/**/*.ts", "packages/*/*/tests/**/*.ts", "scripts/**/*.ts", @@ -342,6 +344,8 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-stdio" }, { "path": "./packages/lsp/tool-lsp" }, - { "path": "./apps/cli" } + { "path": "./apps/cli" }, + { "path": "./apps/desktop-host" }, + { "path": "./apps/desktop" } ] } diff --git a/tsdown.config.ts b/tsdown.config.ts index 5a0fcc8c78..2f24d4910e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,7 +16,9 @@ function isBuildFaceClient(value: unknown): boolean { export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { - workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], + workspace: client + ? ['vendor/*', 'packages/*/*', 'apps/cli'] + : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop', 'apps/desktop-host'], entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'],