From 6615236f238396c25c0b7e2e936f5df5d5237ff0 Mon Sep 17 00:00:00 2001 From: jivaklong Date: Sun, 1 Nov 2026 23:00:11 +0800 Subject: [PATCH] fix(desktop): isolate development runtime and harden plugin activation - give yarn dev its own userData so an installed WorkDSH no longer holds the single-instance lock, and report an early exit instead of staying silent - treat the loader service as optional when activating the workbench client entry - retry Windows process-tree cleanup and fall back to taskkill on query timeout - add DSH version bump tooling, the Profile candidate preparation script and the structure/development guide --- .gitignore | 4 + apps/desktop/build/app-icon.ico | Bin 240843 -> 240843 bytes .../scripts/prepare-workdsh-profile.mjs | 70 ++++ apps/desktop/scripts/start-workdsh.mjs | 14 +- apps/desktop/src/owned-process.ts | 73 ++-- ...00\345\217\221\346\214\207\345\215\227.md" | 357 ++++++++++++++++++ packages/plugins/workbenches/src/client.tsx | 32 +- scripts/bump-dsh-version.mjs | 160 ++++++++ 8 files changed, 678 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/scripts/prepare-workdsh-profile.mjs create mode 100644 "docs/\351\241\271\347\233\256\347\273\223\346\236\204\345\210\206\346\236\220\344\270\216\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" create mode 100644 scripts/bump-dsh-version.mjs diff --git a/.gitignore b/.gitignore index f6f7989470..cc3352da6b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ apps/desktop/build/official-release-cache/ # Local package archives are build outputs. /packages/plugins/*/*.tgz + +# Development desktop data written by `yarn dev`, and local agent notes. +/apps/desktop/build/dev-user-data/ +/.workbuddy/ diff --git a/apps/desktop/build/app-icon.ico b/apps/desktop/build/app-icon.ico index 5d0b30e01905ee3c41702a6002d9bdbcc861b0da..a7ff93f9e756b6e58b6be7ef58543ebfbc7042ac 100644 GIT binary patch delta 40 wcmX?olkfCRz6~C;7(Z?HoHhUT^n%%p9MdoGF|o9NX=MarrtM!^nXjt>0H011V*mgE delta 35 qcmX?olkfCRz6~C;7(Z|JoHhUT^b2zt+1uZ@G6FHv_V=yKchmq?6%s%I diff --git a/apps/desktop/scripts/prepare-workdsh-profile.mjs b/apps/desktop/scripts/prepare-workdsh-profile.mjs new file mode 100644 index 0000000000..82bac4ae6e --- /dev/null +++ b/apps/desktop/scripts/prepare-workdsh-profile.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** Keep the default WorkDSH Profile candidate in sync with the current owned sources. */ +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PACKAGE_DIRECTORIES, RELEASE_PACKAGES } from './workdsh-package-boundary.mjs' +import { verifyBuiltPackageExports } from './verify-profile-release.mjs' +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const root = resolve(desktopRoot, '../..') +const webRoot = join(root, 'apps/web') +const release = join(desktopRoot, 'build/workdsh-profile-release') +const manifestFile = join(release, 'release-manifest.json') +const stampFile = join(release, 'source-fingerprint.txt') +const corepack = process.platform === 'win32' ? 'corepack.cmd' : 'corepack' +if (process.env.WORKDSH_RELEASE_DIRECTORY) { + console.log('WORKDSH_RELEASE_DIRECTORY selects an explicit Profile candidate; packing stays manual') + process.exit(0) +} +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' && command === corepack }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${result.status}`) +} +function hashDirectory(directory, hash) { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name === 'node_modules' || entry.name === '.DS_Store') continue + const path = join(directory, entry.name) + if (entry.isDirectory()) { hashDirectory(path, hash); continue } + if (!entry.isFile()) continue + hash.update(relative(root, path).split(sep).join('/')) + hash.update(readFileSync(path)) + } +} +function hashFile(path, hash) { + hash.update(relative(root, path).split(sep).join('/')) + hash.update(readFileSync(path)) +} +/** Fingerprint everything the packed Profile candidate consumes, independent of git. */ +function sourceFingerprint() { + const hash = createHash('sha256') + for (const name of RELEASE_PACKAGES) hashDirectory(resolve(webRoot, PACKAGE_DIRECTORIES[name]), hash) + hashFile(join(root, 'upstream.json'), hash) + hashFile(join(webRoot, 'package.json'), hash) + hashFile(join(webRoot, 'pnpm-lock.yaml'), hash) + hashFile(join(webRoot, 'scripts/official-web-clients.mjs'), hash) + return hash.digest('hex') +} +function builtExportsPresent() { + try { + for (const name of RELEASE_PACKAGES) { + const directory = resolve(webRoot, PACKAGE_DIRECTORIES[name]) + verifyBuiltPackageExports(JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')), directory) + } + return true + } catch { return false } +} +const fingerprint = sourceFingerprint() +const stamped = existsSync(stampFile) ? readFileSync(stampFile, 'utf8').trim() : null +if (stamped === fingerprint && existsSync(manifestFile)) { + console.log(`WorkDSH Profile candidate at ${release} already matches the current sources`) + process.exit(0) +} +run(corepack, ['pnpm', 'install', '--frozen-lockfile'], webRoot) +if (stamped !== fingerprint || !builtExportsPresent()) run(corepack, ['pnpm', 'build'], webRoot) +// Candidate archives are immutable; replace the default directory atomically per pack run. +rmSync(release, { recursive: true, force: true }) +run(process.execPath, [join(desktopRoot, 'scripts/pack-workdsh-profile.mjs'), webRoot, release]) +writeFileSync(stampFile, fingerprint + '\n') diff --git a/apps/desktop/scripts/start-workdsh.mjs b/apps/desktop/scripts/start-workdsh.mjs index f72bc50d70..9b9b8d6a65 100644 --- a/apps/desktop/scripts/start-workdsh.mjs +++ b/apps/desktop/scripts/start-workdsh.mjs @@ -17,11 +17,18 @@ for (const path of [join(root, 'lib', 'workdsh-main.js'), profile, node, primary const require = createRequire(import.meta.url) const electron = require('electron') +// The single-instance lock is keyed by userData. Keep development data apart +// from an installed WorkDSH so both can run side by side, and so a development +// Profile install never rewrites the installed application's Home. +const userData = process.env.WORKDSH_DESKTOP_USER_DATA ?? join(root, 'build', 'dev-user-data') +console.log(`WorkDSH development data: ${userData}`) +const startedAt = Date.now() const child = spawn(electron, [root], { cwd: root, env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined, + WORKDSH_DESKTOP_USER_DATA: userData, WORKDSH_BUNDLED_PROFILE: profile, WORKDSH_NODE_EXECUTABLE: node, WORKDSH_PRIMARY_RUNTIME: primary, @@ -33,4 +40,9 @@ child.on('error', error => { console.error(error) process.exitCode = 1 }) -child.on('exit', code => { process.exitCode = code ?? 1 }) +child.on('exit', code => { + process.exitCode = code ?? 1 + if (code === 0 && Date.now() - startedAt < 5000) { + console.error(`WorkDSH exited immediately: another instance already owns ${userData}. Close it before running yarn dev again.`) + } +}) diff --git a/apps/desktop/src/owned-process.ts b/apps/desktop/src/owned-process.ts index 3dbd4e545e..04911dd3de 100644 --- a/apps/desktop/src/owned-process.ts +++ b/apps/desktop/src/owned-process.ts @@ -43,7 +43,7 @@ export function selectOwnedWindowsProcesses(rows: WindowsProcessIdentity[], owne return [...generations.values()].filter(row => current.get(row.pid)?.createdAt === row.createdAt) } -async function windowsCommand(script: string, timeoutMs: number): Promise { +async function windowsCommandOnce(script: string, timeoutMs: number): Promise { const executable = process.env.SystemRoot ? join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe') : 'powershell.exe' return new Promise((resolve, reject) => { const task = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -60,6 +60,26 @@ async function windowsCommand(script: string, timeoutMs: number): Promise finish(code === 0 ? undefined : new Error(`Windows 本机进程清理查询失败 (${code})${/access|denied|unauthorized/iu.test(error) ? ',请核对权限' : ''}`))) }) } +/** Run a WMI/powershell command, retrying on a transient query timeout (cold WMI + * start on first launch). `retries` is the number of additional attempts. */ +async function windowsCommand(script: string, timeoutMs: number, retries = 1): Promise { + let lastError: unknown + for (let attempt = 0; attempt <= retries; attempt++) { + try { return await windowsCommandOnce(script, timeoutMs) } + catch (error) { + lastError = error + // Only retry genuine transient timeouts; surface auth/permission/parse errors. + if (error instanceof Error && error.message.includes('Windows 本机进程清理查询超时') && attempt < retries) continue + throw error + } + } + throw lastError instanceof Error ? lastError : new Error('Windows 本机进程清理查询超时') +} +/** Native fallback: a single direct taskkill /T /F on the owned root, bypassing + * the WMI snapshot entirely. Used when the query path keeps timing out. */ +async function windowsKillTreeDirect(pid: number, timeoutMs: number): Promise { + await windowsCommand(`$ErrorActionPreference='Stop'; & "$env:SystemRoot\\System32\\taskkill.exe" /PID ${pid} /T /F`, timeoutMs, 0) +} async function windowsSnapshot(pids: number[], timeoutMs: number): Promise { const filter = [...new Set(pids)].map(pid => `(ProcessId = ${pid} OR ParentProcessId = ${pid})`).join(' OR ') const output = await windowsCommand(`$ErrorActionPreference='Stop'; $rows=@(Get-CimInstance Win32_Process -Filter '${filter}' -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { [pscustomobject]@{pid=[int]$_.ProcessId;ppid=[int]$_.ParentProcessId;createdAt=([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()} }); ConvertTo-Json -InputObject $rows -Compress`, timeoutMs) @@ -80,30 +100,39 @@ function processMissing(pid: number): boolean { async function cleanWindowsTree(child: ChildProcess): Promise { const state = ownership.get(child) if (!state) throw new Error('Windows 本机进程缺少所有权记录,未停止未知进程') - const deadline = Date.now() + 12_000 - const budget = (): number => { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Windows 本机进程树清理超时'); return Math.min(4000, remaining) } - for (let pass = 0; pass < 8; pass++) { - const queried = new Set([state.pid, ...state.known.map(row => row.pid)]) - const rows = await windowsSnapshot([...queried], budget()) - const selected = selectOwnedWindowsProcesses(rows, state, state.known) - for (const row of selected) if (!state.known.some(known => known.pid === row.pid && known.createdAt === row.createdAt)) state.known.push(row) - if (state.known.length > 128) throw new Error('Windows 本机进程树超出安全清理范围') - if (!selected.length) { - const current = rows.find(row => row.pid === state.pid) - if (current && (current.createdAt < state.startedAt || current.createdAt > state.registeredAt)) return // New generation, never ours. - if (processMissing(state.pid)) return - throw new Error('Windows 本机进程树清理未确认') - } - // Discover nested descendants before removing their only visible parent. - if (selected.some(row => !queried.has(row.pid))) continue - for (const row of [...selected].sort((a, b) => b.createdAt - a.createdAt)) { - const result = await windowsTaskkill(row, budget()) - if (result.status === 'taskkill' && result.code !== 0 && !(result.code === 128 && processMissing(row.pid))) { - throw new Error(`Windows 本机进程树清理失败 (${result.code})${/access|denied|权限|拒绝/iu.test(result.output ?? '') ? ',操作被拒绝' : ''},未确认停止`) + const deadline = Date.now() + 45_000 + const budget = (): number => { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Windows 本机进程树清理超时'); return Math.min(15_000, remaining) } + try { + for (let pass = 0; pass < 8; pass++) { + const queried = new Set([state.pid, ...state.known.map(row => row.pid)]) + const rows = await windowsSnapshot([...queried], budget()) + const selected = selectOwnedWindowsProcesses(rows, state, state.known) + for (const row of selected) if (!state.known.some(known => known.pid === row.pid && known.createdAt === row.createdAt)) state.known.push(row) + if (state.known.length > 128) throw new Error('Windows 本机进程树超出安全清理范围') + if (!selected.length) { + const current = rows.find(row => row.pid === state.pid) + if (current && (current.createdAt < state.startedAt || current.createdAt > state.registeredAt)) return // New generation, never ours. + if (processMissing(state.pid)) return + throw new Error('Windows 本机进程树清理未确认') + } + // Discover nested descendants before removing their only visible parent. + if (selected.some(row => !queried.has(row.pid))) continue + for (const row of [...selected].sort((a, b) => b.createdAt - a.createdAt)) { + const result = await windowsTaskkill(row, budget()) + if (result.status === 'taskkill' && result.code !== 0 && !(result.code === 128 && processMissing(row.pid))) { + throw new Error(`Windows 本机进程树清理失败 (${result.code})${/access|denied|权限|拒绝/iu.test(result.output ?? '') ? ',操作被拒绝' : ''},未确认停止`) + } } } + throw new Error('Windows 本机进程树清理未确认') + } catch (error) { + // On a transient WMI timeout/uncertainty (cold start), fall back to a direct + // taskkill /T /F on the owned root before surfacing the original failure. + if (error instanceof Error && /超时|未确认|无效|失败|超出/.test(error.message) && !processMissing(state.pid)) { + try { await windowsKillTreeDirect(state.pid, 15_000); return } catch { /* surface original error below */ } + } + throw error } - throw new Error('Windows 本机进程树清理未确认') } export function cleanOwnedProcessTree(child: ChildProcess): Promise { diff --git "a/docs/\351\241\271\347\233\256\347\273\223\346\236\204\345\210\206\346\236\220\344\270\216\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/\351\241\271\347\233\256\347\273\223\346\236\204\345\210\206\346\236\220\344\270\216\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" new file mode 100644 index 0000000000..ed8d4e23e5 --- /dev/null +++ "b/docs/\351\241\271\347\233\256\347\273\223\346\236\204\345\210\206\346\236\220\344\270\216\344\272\214\346\254\241\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -0,0 +1,357 @@ +# WorkDSH 项目结构分析与 DeepSeek Harness 二次开发指南 + +> 生成日期:2026-10-31 +> 适用仓库:`workdsh`(WorkDSH 产品工作区) +> 配套阅读:`AGENTS.md`(仓库规则)、`docs/architecture.md`、`apps/web/docs/HARNESS-OFFICIAL-DEVELOPMENT.md`、`apps/web/docs/FEATURE-DEVELOPMENT-CONTRACT.md` + +--- + +## 一、项目定位(一句话讲清「它是什么、和 DeepSeek Harness 的关系」) + +**WorkDSH = "Work + DSH"**,是一个运行在 [DeepSeek Harness(简称 DSH)](https://github.com/deepseek-ai/deepseek-harness) 之上的**产品**,而不是从零写的 Agent 内核。 + +- **DeepSeek Harness(主框架/内核)**:由 DeepSeek 开源、以 **Cordis** 为插件内核的 "一切皆插件" Agent 运行框架。模型、工具、技能、会话、沙箱、存储、循环、调度、UI 全部是 Cordis 插件,开发者无需改源码即可在配置层替换/扩展。官方 npm 包以 `@deepseek-ai/dsh-*` 发布,运行时可通过 `npx @deepseek-ai/dsh web` 启动。 +- **WorkDSH(本仓库)**:锁定一个精确版本的官方 DSH 发布包,在其之上叠加自有功能插件(资料库、专家、技能、连接器、Office、项目、企业协作等),再以 **Desktop(Electron 外壳)** 与 **Web** 两种形态交付。 + +**关系结论**:DeepSeek Harness 是「主框架」,WorkDSH 是「运行其上的产品 Profile + carrier(载体)」。本仓库的二次开发目标 = 在**不修改官方核心**的前提下,向这套 Harness 运行时叠加/定制自己的功能插件。 + +--- + +## 二、顶层目录结构与中文名称 / 备注 + +``` +workdsh/ ← 仓库根(WorkDSH 产品工作区) +├── apps/ 【应用】交付形态:桌面端 + Web 端 +├── packages/ 【功能与能力包】WorkDSH 自有 Cordis 插件 / Provider / 契约 / UI / Bundle +├── profiles/ 【运行配置】Profile 组合:决定运行时加载哪些插件与配置 +├── scripts/ 【工程脚本】构建 / 校验 / 发布 / DSH 升降级闸门 +├── docs/ 【文档】产品说明与开发者文档(中英双语 + i18n) +├── assets/ 【静态资源】推广素材与界面截图 +├── patches/ 【依赖补丁】精确到版本的 yarn patch(第三方依赖修正) +├── node_modules/ 【依赖树】根 Yarn 4 工作区依赖 +├── .github/ 【CI/CD】工作流、Issue/PR 模板、发布说明 +├── .agents/ 【Agent 笔记】本地 Agent 工作区记录 +├── .yarn/ 【Yarn 发布】Yarn 4 缓存与发布二进制 +├── upstream.json 【上游锁定】官方 DSH 版本 + 桌面二进制 URL/Size/SHA-512 +├── package.json 【根清单】Yarn 4.18 工作区(仅含 apps/desktop 子工作区) +├── yarn.lock 【锁定文件】根 Yarn 依赖锁 +├── .yarnrc.yml 【Yarn 配置】nodeLinker、预批准官方 Cordis 包、架构 +├── AGENTS.md 【仓库规则】分支/发布/边界/闸门(必读) +└── README*.md 【产品入口】英文 + 中文(zh-CN/zh) +``` + +### 2.1 `apps/` —— 应用(交付形态) + +| 路径 | 包名 | 中文名称 | 备注 | +| --- | --- | --- | --- | +| `apps/desktop` | `dsh-plugin-desktop` | **桌面端(Electron 载体)** | 唯一桌面源码与发布工作区。Electron 外壳运行一套固定版本的 DSH Profile;**不含第二套 DSH**,官方 DSH Host/Client 来自已发布官方运行时 Profile,运行时从校验过的官方 Desktop 安装包提取。 | +| `apps/web` | `workdsh` | **Web 端(自包含工作区)** | 独立 **pnpm** 工作区(自带 `pnpm-lock.yaml`、`pnpm-workspace.yaml`),自包含启动/部署/验证。内含 `docs/`(含 `HARNESS-OFFICIAL-DEVELOPMENT.md` 等二次开发规范)、`examples/`、`website/`、`tests/`、`scripts/`。**不要**把它装进根 Yarn 工作区。 | + +### 2.2 `packages/` —— 功能与能力包(WorkDSH 自有插件) + +| 路径 | 中文名称 | 备注 | +| --- | --- | --- | +| `packages/bundle` | **组合包** | 组合 WorkDSH 工作区、能力与默认 DSH 插件;通过官方 `ctx.plugin(...)` 注册各功能插件,拥有独立子插件生命周期。 | +| `packages/contracts` | **跨插件领域契约** | 类型 / 服务 / schema 的单一来源,供各插件以公开接口协作,避免互相 import 内部实现。 | +| `packages/enterprise-connection` | **企业连接** | 安装后连接公司账号与协作,权限由后台登录验证(仅企业登录后激活,不进个人默认包)。 | +| `packages/tabular` | **表格读写** | 受限的 CSV/XLSX 读取 + 仅原语的 XLSX 写入;无 Office UI、无插件激活。 | +| `packages/ui` | **共享 UI** | 共享组件 / 样式,不含业务页面。 | +| `packages/plugins/` | **功能插件目录(仅归类,非单一 npm 包)** | 每个子目录是独立包、版本、依赖与 CHANGELOG,通过契约协作。见下表。 | +| `packages/providers/` | **提供方目录(仅归类)** | 身份 / 运行 / 资源适配,各子包独立开发、版本化、管理生命周期。见下表。 | + +**`packages/plugins/` 子包(功能插件)** + +| 子目录 | 包名 | 中文名称 | 备注 | +| --- | --- | --- | --- | +| `access` | workdsh-plugin-access | 本地资源访问控制 | 控制对本地资源、会话与工具的访问。 | +| `activity` | workdsh-plugin-activity | 会话活动 | 展示会话活动与 Agent 协作状态。 | +| `audit` | workdsh-plugin-audit | 审计 | 记录本地管理与执行事件,供审计。 | +| `connectors` | workdsh-plugin-connectors | 连接器(MCP) | 管理 MCP 连接器与按会话的工具选择。 | +| `enterprise-collaboration` | workdsh-plugin-enterprise-collaboration | 企业协作 | 隔离 WorkDSH 实例间的成员交接。 | +| `experts` | workdsh-plugin-experts | 专家 | 管理可复用专家与专家团队。 | +| `identity` | — | 身份 | 本地/企业身份相关(与 providers 配合)。 | +| `library` | workdsh-plugin-library | 资料库 | 组织本地文档、修订与任务资源。 | +| `model-policy` | — | 模型策略 | 模型使用策略约束。 | +| `office` | workdsh-plugin-office | Office | 在 WorkDSH 内创建 / 编辑文档工作副本。 | +| `pages` | — | 页面 | 领域页面入口。 | +| `projects` | workdsh-plugin-projects | 项目 | 按项目组织任务、配置与资产。 | +| `quality-analysis` | workdsh-plugin-quality-analysis | 质量分析 | 业务工作台;安装验收进行中。 | +| `skills` | workdsh-plugin-skills | 技能 | 管理已装技能、本地技能包与 SkillHub 浏览。 | +| `tables` | — | 表格 | 表格相关能力。 | +| `usage` | — | 用量 | 用量统计。 | +| `workbench` | workdsh-plugin-workbench | 工作台 | 向 DSH 侧栏增量加入 WorkDSH 导航与 workspace 入口(**不替换官方 Sidebar**)。 | +| `workbenches` | workdsh-plugin-workbenches | 工作台平台 | WorkDSH 工作台平台 Host 服务;Client 管理集成开发中。 | + +**`packages/providers/` 子包(提供方/适配)** + +| 子目录 | 包名 | 中文名称 | 备注 | +| --- | --- | --- | --- | +| `browser-session` | workdsh-provider-browser-session | 浏览器会话 | 将当前 Agent 会话接入受管浏览器。 | +| `identity-local` | workdsh-provider-identity-local | 本地身份 | 可信本地身份与个人 workspace 归属。 | +| `identity-enterprise` | workdsh-provider-identity-enterprise | 企业身份 | 每实例一成员的企业服务端身份适配(开发中)。 | +| `identity-oidc` | — | SSO 身份 | OIDC 单点登录身份(规划骨架)。 | +| `library-team` | — | 团队资产 | 团队资料库(暂不开发)。 | +| `runtime-isolated` | workdsh-provider-runtime-isolated | 隔离执行 | 受信离线包校验与官方工具执行适配器(开发库)。 | + +### 2.3 `profiles/` —— 运行配置(Profile 组合,DSH "配置即组合" 落点) + +| 路径 | 中文名称 | 备注 | +| --- | --- | --- | +| `profiles/shared/` | **个人与企业共同运行来源** | `workdsh-features.json`:官方 bundle(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`)+ 自有功能包清单(**不等于全部默认安装**);`official-web-clients.json`:核对完整官方客户端元数据。 | +| `profiles/enterprise/` | **企业 Profile** | 单 ECS 按账号进程的实施与验收边界(企业账号独立官方进程运行)。 | +| `profiles/README.md` | 配置说明 | 个人/企业共用同一精确锁定官方版本族;企业身份、协作、通知显式外置。 | + +> 这正是 DSH「在配置层自由组合能力」思想的体现:`profiles/` 决定**加载哪些插件**,而非改源码。 + +### 2.4 `scripts/` —— 工程脚本(构建 / 校验 / 发布 / 版本闸门) + +| 脚本 | 中文作用 | +| --- | --- | +| `bump-dsh-version.mjs` | 升降级 DSH 锁定版本(npm 与二进制分开,二进制需手工审查,绝不猜测)。 | +| `preflight-dsh-upgrade.mjs` | 升级前预检:核对自有 manifest 与公开包,**不改源码/Profile**。 | +| `verify-desktop-dsh-alignment.mjs` | 校验桌面端与 DSH 版本一致(版本闸门)。 | +| `verify-web-desktop-dsh-alignment.mjs` | 校验 Web 端与桌面端 DSH 版本一致。 | +| `verify-bilingual-docs.mjs` / `bilingual-docs.mjs` | 双语文档核对(中英文 + `.i18n.yaml` blob hash)。 | +| `verify-layout.mjs` / `verify-delivery-paths.mjs` | 目录迁移与交付路径回归(源级移动不完整即报错)。 | +| `native-workbench-frame-patch.*` | 授权外框补丁(仅改两道外框缝,恢复原生完整 Conversation;`fail closed`)。 | +| `package-desktop-release.mjs` | 打包桌面发布物(本地 / 已发布来源)。 | +| `prepare-test-electron.mjs` | 测试前准备 Electron 运行时(惰性安装、避免并发解压)。 | +| `check-local-web-plan.mjs` / `classify-ci-changes.mjs` / `ci-scope.test.mjs` | CI 范围分类与本地 Web 计划检查。 | +| `*.test.mjs` | 对应脚本的单元测试。 | + +### 2.5 `docs/` —— 文档(中英双语) + +| 文件 | 中文说明 | +| --- | --- | +| `README.md` | 文档索引(指向英文/中文各篇)。 | +| `architecture.md` / `architecture.en.md` | Desktop 架构(Electron 外壳 + 固定 DSH Profile)。 | +| `desktop-boundaries.md` | Desktop 归属约束(唯一桌面源码、不得第二套 DSH)。 | +| `plugin-development.md` | 插件开发指南。 | +| `plugin-ecosystem.md` | 插件生态倡议(长期方向,暂无在线市场)。 | +| `user-guide.md` / `faq.md` / `why-desktop.md` | 用户指南 / 常见问题 / 产品定位。 | +| `ENTERPRISE-PLUGIN-AUTH.md` | 企业插件授权。 | +| `DESKTOP-PERSONAL-ENTERPRISE.md` | 桌面个人与企业使用说明。 | +| `evidence/` / `images/` | 验收证据(不进公开源码提交)/ 配图。 | + +> 双语约定:每篇 `.md` 配 `.en.md`,并在对应 `.i18n.yaml` 记录 Git blob hash;改文档须同步中英与 i18n 记录,否则 `yarn check:bilingual-docs` 失败。 + +### 2.6 其余目录 + +| 路径 | 中文名称 | 备注 | +| --- | --- | --- | +| `assets/promotion/` `assets/screenshots/` | 推广素材 / 截图 | 产品宣传与界面截图。 | +| `patches/app-builder-lib@26.15.7.patch` | 构建依赖补丁 | 精确版本 yarn patch(electron-builder 相关)。 | +| `.github/workflows/` | CI 工作流 | `ci.yml`(含 check 闸门)、`pages.yml`(文档站点)。 | +| `.github/release-notes/` | 发布说明 | 桌面各 alpha 版本 release note。 | +| `.agents/notes/` | Agent 笔记 | 本地智能体工作区笔记。 | +| `.yarn/` | Yarn 发布 | Yarn 4 缓存与 release 二进制(非源码)。 | + +--- + +## 三、核心机制:WorkDSH 如何「装进」DeepSeek Harness + +``` +┌──────────────────────────────────────────────────────────┐ +│ DeepSeek Harness(主框架 / 官方 @deepseek-ai/dsh-*) │ +│ Cordis 内核:只管插件挂载/卸载/依赖;能力全在插件里 │ +│ 官方插件:model / tool / skill / session / sandbox / │ +│ storage / loop / schedule / UI … │ +└──────────────────────────────────────────────────────────┘ + ▲ 不修改核心,只叠加(Profile + 自有插件) +┌──────────────────────────────────────────────────────────┐ +│ WorkDSH 叠加层(本仓库) │ +│ • profiles/shared/workdsh-features.json → 选哪些官方+自有 │ +│ • packages/plugins/*、packages/providers/* → Cordis 插件 │ +│ • packages/bundle → ctx.plugin(...) 注册 │ +│ • apps/desktop(Electron carrier)/ apps/web(自包含) │ +└──────────────────────────────────────────────────────────┘ +``` + +**三个锁定层(升级时必须一起对齐)**: +1. `upstream.json` —— 官方 DSH 发布版本 + 桌面二进制 URL / Size / SHA-512(checksum 锁定)。 +2. `profiles/shared/workdsh-features.json` —— 官方 bundle(`dsh-base`、`dsh-web-app`)+ 自有功能包清单。 +3. 各包 `package.json` —— DSH npm 依赖通过 `@deepseek-ai/*`(Cordis 等)精确版本(见 `.yarnrc.yml` 的 `npmPreapprovedPackages`)。 + +**两种启动形态**: +- **Desktop**:`apps/desktop/src/workdsh-main.ts` 是唯一入口;打包脚本读取 `upstream.json` 准备并验证 Profile、官方主运行时与安装包;用官方 `runProfile` / `runCli` 接口启动同一 Host,传入可写 Profile 与安装锚点;`app.asar` 只含 Electron 入口,不含第二套 `node_modules`。 +- **Web**:`apps/web` 自包含 pnpm 工作区,安装官方客户端 + WorkDSH 功能包,由官方唯一 renderer 挂载 TSX 页面。 + +--- + +## 四、如何接入主框架 / 二次开发(落地步骤) + +### 4.1 环境准备 +- **Node**:`^22.19.0` 或 `>=24.0.0`(Desktop 还依赖 `upstream.json` 锁定的 primaryRuntime:Node 24.x / pnpm 11.x / Python 3.12)。 +- **包管理器**:根用 **Yarn 4.18(Corepack)**;`apps/web` 用其自带 **pnpm**(两者锁文件独立,不要混装)。 + +### 4.2 安装依赖 +```bash +# 桌面端(根 Yarn 工作区) +corepack yarn install --immutable + +# Web 端(进入其独立工作区,用自带 pnpm 锁文件) +cd apps/web && pnpm install +``` + +### 4.3 开发启动 +```bash +# 桌面开发:build → 准备 workdsh-runtime / primary-runtime / command → 启动 Electron +corepack yarn dev + +# 桌面构建 / 类型检查 / 测试 / 全量无头闸门 +corepack yarn build +corepack yarn typecheck +corepack yarn test +corepack yarn check # layout + desktop/web DSH 对齐 + web-plan + desktop check + +# Web 端命令见 apps/web/package.json(其脚本独立于根工作区) +``` + +### 4.4 开发一个新功能插件(标准姿势,对照 `HARNESS-OFFICIAL-DEVELOPMENT.md`) +1. **建包**:在 `packages/plugins/` 新建 Cordis 插件包,导出标准 `name / inject / apply`;通过 `packages/bundle` 用官方 `ctx.plugin(...)` 注册,拥有独立子插件生命周期。 +2. **UI 扩展只走官方公开 Slot**:侧栏用新的稳定 `list id`(与 `main` keyed entry 配对),右栏用 `rightbar.session`,浮层用 `shell.overlay`。**绝不替换**官方 Sidebar / Conversation / Composer / 模型设置 / 插件管理。 +3. **跨插件只依赖公开契约**:类型/服务取自 `packages/contracts`,不 import 内部组件或数据表;React 组件**不接收** Cordis `ctx`、Remote service、凭据或领域服务。 +4. **依赖边界**:Cordis 与官方 Harness 运行依赖用 `peerDependencies` 声明兼容边界,开发依赖精确锁定(参考 `.yarnrc.yml` 的 `npmPreapprovedPackages`);Client bundle 的 `dsh.client.inject` 记录所需官方模块。 +5. **登记模块**:在 `apps/web/docs/modules.json` 登记新模块,与 workspace、包入口、依赖、requirements 同步;默认/可选/企业激活/外置安装按 `EXTERNAL-PLUGINS.md` 边界处理。 +6. **有限验收**:typecheck、build、独立安装检查完成后,再分别判定真实模型 / 界面 / 平台运行;源码构建通过 ≠ 真实运行/UI/安装验收。 + +### 4.5 版本与一致性闸门(升级 DSH 时) +```bash +# 1) 改 upstream.json 的版本 +node scripts/bump-dsh-version.mjs +# 2) 升级前预检(不改源码/Profile,仅核对) +node scripts/preflight-dsh-upgrade.mjs +# 3) 全量闸门(含 desktop/web DSH 对齐) +corepack yarn check +``` +> 版本闸门(`check:desktop-dsh-alignment` / `check:web-dsh-alignment`)不通过,**不得**打 Desktop 发布 tag。匹配产物哈希 ≠ 打包运行时验收。 + +### 4.6 红线(不可逾越的边界,`AGENTS.md`) +- ❌ 不得克隆 / import / 编译官方 DSH 源码;官方运行时只**提取**依赖、Office 资源、预构建命令助手。 +- ❌ 不得自带第二套 DSH 安装 / 第二套 Desktop Host 或 Client。 +- ❌ 不得将登录凭据、模型 Key 打入安装包(仅 `workdsh-config.json` 预设公开后端 origin)。 +- ❌ 企业能力仅登录后激活,不得进个人或 Desktop 默认包。 +- ❌ 新增 UI 入口不得伪造未接入的团队权限 / 业务对象 / 管理能力。 + +--- + +## 五、如何更新 DeepSeek Harness 主框架(升级 DSH 锁定版本) + +WorkDSH 对官方 DSH 采用「**单点锁定 + 全量对齐**」策略:`upstream.json` 是唯一的版本真相源,所有 `@deepseek-ai/dsh*` 依赖与桌面二进制都从它派生并必须一致。升级不是改一个数字,而要同步 **npm 包、桌面二进制、外框补丁、运行时** 四处,并通过两套对齐闸门。 + +### 5.1 升级前红线 + +- 默认采用上游**最新正式稳定版**,经兼容性验证后升级;预发布(rc/alpha)仅在有明确产品决策时使用。 +- **严禁**克隆 / 导入 / 编译官方 DSH 源码;Desktop 不得声明任何额外的 `@deepseek-ai/dsh*`(carrier 不含第二套 DSH 安装),根 `package.json` 的 `resolutions` 不得残留旧 DSH 包。 +- macOS Intel(`mac-x64`)归档可因官方未发布对应 Intel 版本而停留在较早发布版(如 `rc.4`),**仅**用于 Node/pnpm/Python/Office 资源与预构建命令助手;它**不得**供应 DSH Host/Client/SDK 包,也不得改标为 `rc.5` 或做自动回退。 +- `native-workbench-frame-patch` 只能改两道外框缝(恢复原生 Conversation),未知字节必须 fail closed,保留精确回滚。 + +### 5.2 关键配置文件(升级会触碰的地方) + +| 文件 | 角色 | +| --- | --- | +| `upstream.json` | 唯一版本真相源:`version`(npm 主版本)、`desktopArtifacts`(各平台 `url`/`size`/`sha512`)、`primaryRuntime`(node/pnpm/python)、`distribution` | +| `apps/desktop/scripts/runtime-version.mjs` | 直接读 `upstream.json.version` 得到 `DSH_VERSION`(**派生量,勿手改**) | +| `scripts/native-workbench-frame-patch.json` | 锁定 `package`+`version`+`originalSha256`+`patchedSha256`+`replacements`(两缝补丁) | +| `apps/web/package.json`(`pnpm.overrides`) | 锁定 `@deepseek-ai/cordis`/`schemastery`/`cordis-plugin-*` 等运行依赖版本 | +| 各 `packages/**/package.json` + `apps/web/package.json` | 声明的 `@deepseek-ai/dsh*` 精确版本必须等于 `upstream.json.version` | + +### 5.3 标准升级流程 + +```bash +# 0) 选定目标版本 V(必须是 npm 上已发布的精确版本;默认最新正式稳定版) +# 预发布需明确决策。先确认 @deepseek-ai/dsh 及直接/peer 依赖都已发布。 + +# 1) 预检 + 变更预览(dry run,不改任何文件) +node scripts/preflight-dsh-upgrade.mjs --version= +node scripts/bump-dsh-version.mjs --version= +# → 报告:将要改的 pin、unexpected(既非旧版也非目标版的脏 pin)、 +# blocking(必须手改项)、review(需人工核对项) + +# 2) 手动消解 blocking(缺一不可) +# a. 更新 upstream.json.desktopArtifacts 各平台为官方发布的 url/size/sha512 +# (mac-x64 Intel 按 5.1 红线处理,仅作运行时助手,不供 DSH 包) +# b. 重新派生 scripts/native-workbench-frame-patch.json: +# version + originalSha256/patchedSha256 + 两缝 replacements(fail closed) +# c. 用新归档的 runtime.json 核对 upstream.json.primaryRuntime(node/pnpm/python) +# d. 核对 apps/web/package.json 的 pnpm.overrides(cordis/schemastery/...) +# 是否仍符合新发布的依赖范围 + +# 3) 应用版本改写(仅改写恰好等于旧版本的精确 pin,并改写 upstream.json.version) +node scripts/bump-dsh-version.mjs --version= --apply +# → 拒绝写回情形:某 pin 不是字面量旧版本;或写回后旧版本仍有残留 + +# 4) 安装依赖并跑对齐闸门 +corepack yarn install --immutable # 桌面侧(根 Yarn) +cd apps/web && pnpm install # Web 侧(独立 pnpm,重建锁文件) +corepack yarn check:dsh-upgrade # npm 发布 + 桌面 feed 预检 +corepack yarn check:desktop-dsh-alignment # 桌面与 upstream 版本/二进制一致 +corepack yarn check:web-dsh-alignment # Web 全部 @deepseek-ai/dsh* == upstream.version + +# 5) 构建与全量无头闸门 +corepack yarn build +corepack yarn typecheck +corepack yarn test +corepack yarn check # layout + desktop/web DSH 对齐 + web-plan + desktop check + +# 6) 打包运行时与 UI 验收(Desktop 必做) +# 准备 workdsh-runtime / primary-runtime / command → package:dir / dist → +# 启动安装包验证原生 Conversation 与 UI 通过。 +# 匹配产物哈希 ≠ 打包运行时验收;版本闸门不通过不得打 Desktop 发布 tag。 + +# 7) 经评审的 PR 合入默认分支 main,并删除临时分支。 +``` + +### 5.4 两套对齐闸门在查什么 + +- **`check:desktop-dsh-alignment`**(`verify-desktop-dsh-alignment.mjs`):`upstream.version === DSH_VERSION`;`distribution` 必须为 `published-packages-and-desktop-release`;三个桌面二进制源必须显式锁定且格式合法(URL/size/SHA-512);Desktop 不得声明额外 `@deepseek-ai/dsh*`(每多一个即报错);根 `resolutions` 不得残留旧 DSH 包。 +- **`check:web-dsh-alignment`**(`verify-web-desktop-dsh-alignment.mjs`):`apps/web` 下所有 `@deepseek-ai/dsh*` 依赖版本必须全部等于 `upstream.json.version`,否则列出不匹配项。 +- **`check:dsh-upgrade`**(`preflight-dsh-upgrade.mjs`):向 npm registry 核对候选版本每个直接/peer DSH 包都已发布(https + 完整 SHA-512),并核对三个平台的桌面 feed 是否已广告该候选版本;结果写入 `.artifacts/workdsh-3.0/kernel-preflight.json`。 + +### 5.5 常见坑 + +- **脏 pin(unexpected)**:若某 `@deepseek-ai/dsh*` 既不是旧版本也不是目标版本(例如被手改过),bump 会拒绝改写并列出,需先手动归一。 +- **桌面二进制未同步**:只改 npm 版本不改 `desktopArtifacts` 会被 bump 标为 blocking,且打包时会因二进制源未锁定而失败。 +- **外框补丁未重算**:官方 `dsh-client-ui-conversation` 字节变化会导致 `originalSha256` 不匹配,必须按两缝重新派生,否则桌面原生 Conversation 恢复失效。 +- **pnpm overrides 漂移**:cordis/schemastery 等运行依赖随 DSH 发布变化,升级后需在 `apps/web/package.json` 的 `pnpm.overrides` 中确认范围,否则可能出现双重 Cordis 实例。 + +## 六、二次开发的目录落点速查 + +| 你想做的事 | 落点目录 | +| --- | --- | +| 加业务功能(资料库/专家/技能/Office/项目…) | `packages/plugins/` | +| 加身份 / 运行 / 资源适配 | `packages/providers/<...>` | +| 跨插件共享类型 / 服务 / schema | `packages/contracts` | +| 共享 UI 组件 / 样式 | `packages/ui` | +| 改「运行时加载哪些插件 / 配置」 | `profiles/shared/workdsh-features.json`(及 `profiles/enterprise`) | +| 改构建 / 校验 / 发布 / 版本闸门 | `scripts/` | +| 改桌面外壳 / 打包 / 安装 | `apps/desktop/` | +| 改 Web 启动 / 部署 / 验证 | `apps/web/` | +| 改产品说明 / 开发者文档 | `docs/`(须同步中英 + i18n) | + +--- + +## 七、常用命令速查 + +| 场景 | 命令 | +| --- | --- | +| 桌面开发启动 | `corepack yarn dev` | +| 桌面构建 | `corepack yarn build` | +| 桌面类型检查 | `corepack yarn typecheck` | +| 桌面测试 | `corepack yarn test` | +| 全量无头闸门 | `corepack yarn check` | +| 桌面/Web 与 DSH 对齐校验 | `corepack yarn check:desktop-dsh-alignment` / `corepack yarn check:web-dsh-alignment` | +| 双语文档校验 | `corepack yarn check:bilingual-docs` | +| DSH 版本预检 | `node scripts/preflight-dsh-upgrade.mjs` | +| 打包桌面发布物 | `node scripts/package-desktop-release.mjs --source=local\|published` | +| Web 依赖安装 | `cd apps/web && pnpm install` | + +--- + +*本文件为仓库结构梳理与二次开发入口指引,权威规则以 `AGENTS.md`、`docs/`、`apps/web/docs/` 内各规范为准。* + +$env:WORKDSH_BOOTSTRAP_ADMIN_EMAIL="admin@example.com";$env:WORKDSH_BOOTSTRAP_ADMIN_PASSWORD="Admin@1234567890"; mvn -f server/pom.xml spring-boot:run + +admin@example.com +Admin@1234567890 + diff --git a/packages/plugins/workbenches/src/client.tsx b/packages/plugins/workbenches/src/client.tsx index 550748d945..7d929a20db 100644 --- a/packages/plugins/workbenches/src/client.tsx +++ b/packages/plugins/workbenches/src/client.tsx @@ -16,26 +16,40 @@ import { WORKBENCH_MANAGER_PANEL } from './client/native-page.js'; import {createWorkbenchShortcuts} from './client/shortcuts.js'; export const name = 'workdsh-workbenches-client'; -export const inject = ['slots', 'layout', 'sessions', 'conversation', 'uiWorkspace', 'loader']; +export const inject = ['slots', 'layout', 'sessions', 'conversation', 'uiWorkspace']; /** Native management, authoring and business page registration entry. * Registration and installation receipts remain owned by Host services. */ export function apply(ctx: Context) { const shortcuts=createWorkbenchShortcuts(); ctx.effect(()=>{void shortcuts.refresh();const refresh=()=>void shortcuts.refresh();window.addEventListener('focus',refresh);return()=>{window.removeEventListener('focus',refresh);shortcuts.close();};},'workdsh.shortcuts.client'); const authoringNavigation=createAuthoringNavigation(); - const openAuthoring=(view:'create'|'developments',target?:{developmentId?:string;creationId?:string})=>{authoringNavigation.open(view,target);ctx.layout.selectPanel(WORKBENCH_AUTHORING_PANEL as MainPanelId);}; + // `loader` is provided by the Host (Desktop carrier / Web Host), not by any + // WorkDSH plugin package. Treat it as optional so a missing loader in a given + // runtime profile degrades the native authoring surface instead of blocking the + // whole client entry from activating. + let loader: WorkbenchLoader | undefined; + try { loader = ctx.get('loader') as WorkbenchLoader | undefined; } catch { loader = undefined; } + const hasLoader = loader !== undefined; + let pagePorts: ReturnType | undefined; + let registration: ClientWorkbenchRegistration | undefined; + // Build the native authoring surface only when a Host-provided `loader` + // exists. Inside this block `loader` is narrowed to a defined value, so it is + // safe to hand to the registration constructor. + if (loader) { + pagePorts = createNativeWorkbenchPagePorts(ctx, shortcuts); + registration = new ClientWorkbenchRegistration(loader, createWorkbenchRegistrationClient(), pagePorts); + } + const openAuthoring=(view:'create'|'developments',target?:{developmentId?:string;creationId?:string})=>{if(!hasLoader||!pagePorts)return;authoringNavigation.open(view,target);ctx.layout.selectPanel(WORKBENCH_AUTHORING_PANEL as MainPanelId);}; function AuthoringPage(){ const entryRequest=useSyncExternalStore(authoringNavigation.subscribe,authoringNavigation.getSnapshot,authoringNavigation.getSnapshot); return ctx.slots.entriesOfSlot('main').some(row=>row.options.key===`workdsh-workbench:${id}`)} onOpen={id=>{const key=`workdsh-workbench:${id}`;if(ctx.slots.entriesOfSlot('main').some(row=>row.options.key===key))ctx.layout.selectPanel(key as MainPanelId);}}/>; } - const loader = ctx.get('loader') as WorkbenchLoader | undefined; - if (!loader) throw new Error('workbench/client-loader-unavailable'); - const pagePorts=createNativeWorkbenchPagePorts(ctx,shortcuts); - const registration = new ClientWorkbenchRegistration(loader, createWorkbenchRegistrationClient(), pagePorts); - ctx.effect(() => () => registration.close(), 'workdsh.workbench.client-registration.close'); - ctx.provide('workdshWorkbenchClientRegistration', registration); + if (registration) { + ctx.effect(() => () => registration.close(), 'workdsh.workbench.client-registration.close'); + ctx.provide('workdshWorkbenchClientRegistration', registration); + } const close = () => { const manager = ctx.slots.entriesOfSlot('main').some(entry => entry.options.key === WORKBENCH_MANAGER_PANEL); ctx.layout.selectPanel(manager ? WORKBENCH_MANAGER_PANEL as MainPanelId : null); @@ -53,7 +67,7 @@ export function apply(ctx: Context) { catch (cause) { page(); throw cause; } return () => { try { if (ctx.layout.panelInfo.getSnapshot().activePanelId === WORKBENCH_MANAGER_PANEL) ctx.layout.selectPanel(null); } finally { try { navigation(); } finally { page(); } } }; })); - ctx.slots.inject('main', () => { + if (hasLoader && pagePorts) ctx.slots.inject('main', () => { const lifetime=new AbortController(); // This is the platform's own authoring surface, not an unverified business // registration. Reuse the one native frame and its original Conversation. diff --git a/scripts/bump-dsh-version.mjs b/scripts/bump-dsh-version.mjs new file mode 100644 index 0000000000..ec78935544 --- /dev/null +++ b/scripts/bump-dsh-version.mjs @@ -0,0 +1,160 @@ +import { readFile, writeFile, readdir } from 'node:fs/promises' +import { join, resolve, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { inspectUpgrade } from './preflight-dsh-upgrade.mjs' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const isDshPackage = name => name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-') +const dependencyFields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] +// Binary/runtime companions are locked independently of the npm release and +// must be reviewed by hand; this tool never guesses them. +const runtimePackages = ['@deepseek-ai/cordis', '@deepseek-ai/schemastery', '@deepseek-ai/cordis-plugin-group', '@deepseek-ai/cordis-plugin-loader', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/cordis-plugin-include'] + +/** Every owned manifest that may pin the official published release. */ +export async function collectManifests(repositoryRoot) { + const files = [join(repositoryRoot, 'apps/web/package.json')] + async function visit(directory, maxDepth, depth = 0) { + let entries + try { entries = await readdir(directory, { withFileTypes: true }) } catch (error) { if (error.code === 'ENOENT') return; throw error } + for (const entry of entries) { + if (entry.name === 'package.json') files.push(join(directory, entry.name)) + else if (entry.isDirectory() && depth < maxDepth && !['node_modules', 'dist', 'lib', 'src', 'tests', 'assets', 'docs'].includes(entry.name)) await visit(join(directory, entry.name), maxDepth, depth + 1) + } + } + await visit(join(repositoryRoot, 'packages'), 3) + await visit(join(repositoryRoot, 'apps/web/examples'), 6) + const manifests = [] + for (const path of files) manifests.push({ path, manifest: JSON.parse(await readFile(path, 'utf8')) }) + return manifests +} + +export function pinnedDshVersions(manifests) { + const versions = new Map() + for (const { manifest } of manifests) { + const fields = [...dependencyFields.map(field => [field, manifest[field]]), ['pnpm.overrides', manifest.pnpm?.overrides]] + for (const [field, entries] of fields) { + for (const [name, version] of Object.entries(entries ?? {})) { + if (!isDshPackage(name)) continue + const values = versions.get(name) ?? new Set() + values.add(version) + versions.set(name, values) + } + } + } + return versions +} + +/** Only exact pins of the currently locked release are rewritten. */ +export function planVersionChange(manifests, from, to, repositoryRoot = root) { + const changes = [], unexpected = [], already = [] + for (const { path, manifest } of manifests) { + const fields = [...dependencyFields.map(field => [field, manifest[field]]), ['pnpm.overrides', manifest.pnpm?.overrides]] + for (const [field, entries] of fields) { + for (const [name, version] of Object.entries(entries ?? {})) { + if (!isDshPackage(name)) continue + const file = relative(repositoryRoot, path).replaceAll('\\', '/') + if (version === to) already.push({ file, field, name }) + else if (version === from) changes.push({ file, field, name, from: version, to }) + else unexpected.push({ file, field, name, version }) + } + } + } + return { changes, unexpected, already } +} + +const escapePattern = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** Text substitution keeps the repository's existing JSON formatting intact. */ +export async function applyVersionChange(repositoryRoot, from, to, changes) { + const byFile = new Map() + for (const change of changes) byFile.set(change.file, [...(byFile.get(change.file) ?? []), change]) + const written = [] + for (const [file, rows] of byFile) { + const path = join(repositoryRoot, file) + let text = await readFile(path, 'utf8') + for (const row of rows) { + const pattern = new RegExp(`"${escapePattern(row.name)}":\\s*"${escapePattern(from)}"`) + if (!pattern.test(text)) throw new Error(`Refusing to rewrite ${file}: ${row.field}.${row.name} is not a literal ${from} pin`) + text = text.replace(pattern, () => `"${row.name}": "${to}"`) + } + const confirm = pinnedDshVersions([{ path, manifest: JSON.parse(text) }]) + for (const row of rows) { + const values = confirm.get(row.name) ?? new Set() + if (values.has(from)) throw new Error(`Refusing to write ${file}: ${row.name} still pins ${from}`) + } + await writeFile(path, text) + written.push(file) + } + return written +} + +export async function bumpDshVersion(repositoryRoot, { version, apply = false, skipPreflight = false, request = fetch }) { + if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(version)) throw new Error('Target must be one exact published version') + const upstreamPath = join(repositoryRoot, 'upstream.json') + const upstream = JSON.parse(await readFile(upstreamPath, 'utf8')) + const from = upstream.version + const manifests = await collectManifests(repositoryRoot) + const plan = planVersionChange(manifests, from, version, repositoryRoot) + const framePatch = JSON.parse(await readFile(join(repositoryRoot, 'scripts/native-workbench-frame-patch.json'), 'utf8')) + const current = pinnedDshVersions(manifests) + const report = { + candidate: version, + current: from, + apply, + changes: plan.changes, + unexpectedPins: plan.unexpected, + alreadyPinned: plan.already.length, + blocking: [], + review: [], + written: [], + } + if (!skipPreflight) { + const preflight = await inspectUpgrade(repositoryRoot, version, request) + report.preflight = { + publicationVerified: preflight.publicationVerified, + missingDirectPackages: preflight.missingDirectPackages, + obsoleteOverrideCandidates: preflight.obsoleteOverrideCandidates, + failedRequests: preflight.packages.filter(item => item.status === 'request-failed').map(item => item.name), + desktopFeeds: preflight.desktopFeeds.map(({ target, status, feedVersion }) => ({ target, status, feedVersion })), + desktopCandidateAdvertised: preflight.desktopCandidateAdvertised, + } + if (!preflight.publicationVerified || report.preflight.failedRequests.length) { + report.blocking.push('Registry preflight failed: every direct/peer DSH package must be published at ' + version) + return report + } + } + for (const [target, artifact] of Object.entries(upstream.desktopArtifacts ?? {})) { + if (artifact.version !== version) report.blocking.push(`Desktop ${target} official archive is still ${artifact.version}; collect the published URL, size and SHA-512 before packaging`) + } + if (framePatch.version !== version) report.blocking.push(`native-workbench-frame-patch locks ${framePatch.package}@${framePatch.version}; re-derive originalSha256/patchedSha256/replacements for the two authorized outer-frame seams before launching`) + if (upstream.primaryRuntime) report.review.push(`primaryRuntime stays node=${upstream.primaryRuntime.node} pnpm=${upstream.primaryRuntime.pnpm} python=${upstream.primaryRuntime.python}; verify against the new archive runtime.json`) + const webOverrides = manifests.find(item => item.path === join(repositoryRoot, 'apps/web/package.json'))?.manifest.pnpm?.overrides ?? {} + for (const name of runtimePackages) { + if (webOverrides[name]) report.review.push(`${name} stays pinned at ${webOverrides[name]}; confirm the new release's own range before installing`) + } + report.pinnedPackageCount = current.size + if (!apply) return report + + report.written = await applyVersionChange(repositoryRoot, from, version, plan.changes) + if (from !== version) { + const text = await readFile(upstreamPath, 'utf8') + const next = text.replace(new RegExp(`"version":\\s*"${escapePattern(from)}"`), () => `"version": "${version}"`) + if (JSON.parse(next).desktopArtifacts !== undefined && JSON.parse(next).version !== version) throw new Error('Refusing to write upstream.json: top-level version was not replaced') + await writeFile(upstreamPath, next) + report.written.push('upstream.json') + } + return report +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const version = process.argv.find(argument => argument.startsWith('--version='))?.slice(10) + if (!version) throw new Error('Pass --version=') + const apply = process.argv.includes('--apply') + const report = await bumpDshVersion(root, { version, apply, skipPreflight: process.argv.includes('--skip-preflight') }) + console.log(JSON.stringify(report, null, 2)) + console.log(apply ? 'Rewrite applied. Continue with the maintained gates.' : 'Dry run only. Re-run with --apply to rewrite the version pins.') + if (report.blocking.length) { + console.error('Outstanding manual steps:\n' + report.blocking.join('\n')) + process.exitCode = 1 + } +}