From ff1abee438e0ad3fd48d06664a4720beacac1c08 Mon Sep 17 00:00:00 2001 From: wangheng Date: Mon, 2 Nov 2026 23:30:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E5=90=8E=E6=95=B4=E4=BD=93=E6=9B=BF=E6=8D=A2=E5=BE=85=E5=86=99?= =?UTF-8?q?=E9=98=9F=E5=88=97=EF=BC=8C=E4=BF=AE=E9=A6=96=E5=B1=8F=E8=87=AA?= =?UTF-8?q?=E6=84=88=E6=8A=A2=E8=B7=91=E5=86=99=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_content/dsh.plugin.json | 2 +- 01_content/lib/client.js | 30 +++++++++++++++++------------- 01_content/lib/index.js | 25 +++++++++++++++++++++++-- 01_content/package.json | 2 +- 01_content/src/client/hostState.ts | 14 +++++++++----- 01_content/src/client/index.tsx | 13 ++++++++----- 01_content/src/index.ts | 30 ++++++++++++++++++++++++++---- PATCH.md | 14 +++++++++----- README.md | 14 +++++++------- 9 files changed, 101 insertions(+), 43 deletions(-) diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index 6fd7a25..4dc99a6 100644 --- a/01_content/dsh.plugin.json +++ b/01_content/dsh.plugin.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目", - "version": "0.5.2-local.3", + "version": "0.5.2-local.6", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 05c1816..499db8d 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -9160,7 +9160,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.5.1 \u30103.1 \u6587\u5B57\u4E0E\u7EC6\u8282\u6253\u78E8\u3011\u5168\u5C40\u5B57\u4F53\u4E0E\u5B57\u53F7\u7EDF\u4E00\u4F18\u5316\u3001\u4E0B\u62C9\u9762\u677F\u73BB\u7483\u5316\u4E0E\u5BF9\u9F50\u3001\u83DC\u5355\u70B9\u9009\u540E\u4FDD\u6301\u6253\u5F00\u4FBF\u4E8E\u8FDE\u7EED\u9884\u89C8\u3001\u6309\u94AE\u63CF\u8FB9\u4E0E\u60AC\u505C\u53CD\u9988\u7B49\u4EA4\u4E92\u7EC6\u8282\uFF1B\u540C\u65F6\u4FEE\u590D\u4E86\u591A\u9879\u4F53\u9A8C\u95EE\u9898\uFF08\u7167\u7247\u4E0A\u4F20\u6E05\u6670\u5EA6\u3001\u80CC\u666F\u7F51\u683C\u7EBF\u5728\u7167\u7247\u6A21\u5F0F\u4E0B\u4E0D\u751F\u6548\u3001\u6D45\u8272\u4E3B\u9898\u4E0B\u5DE5\u4F5C\u72B6\u6001\u5149\u6548\u4E0D\u53EF\u89C1\u7B49\uFF09\u3002`; // src/client/updateCheck.ts -var LOCAL_VERSION = false ? "dev" : "0.5.2-local.3"; +var LOCAL_VERSION = false ? "dev" : "0.5.2-local.6"; var UPDATE_REPO = "Aisland-SJL/dsh-worktable"; var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1"; var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1"; @@ -20736,11 +20736,14 @@ var WAVE_BG_B64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQUFBAYFBQUHBgYHCQ8KCQgIC // src/client/hostState.ts var ENDPOINT = "/api/worktable/state"; var RETRY_DELAYS = [0, 300, 700, 1200, 2e3, 3e3]; -async function loadHostState() { +async function loadHostState(localAt = 0) { for (const delay of RETRY_DELAYS) { if (delay) await new Promise((r) => setTimeout(r, delay)); try { - const r = await fetch(ENDPOINT, { cache: "no-store", headers: { accept: "application/json" } }); + const r = await fetch(ENDPOINT + "?localAt=" + encodeURIComponent(String(localAt)), { + cache: "no-store", + headers: { accept: "application/json" } + }); if (!r.ok) continue; const j = await r.json(); if (j && typeof j === "object" && j.state && typeof j.state === "object") { @@ -20792,8 +20795,8 @@ function queueHostSave(patch) { if (!hydrated) return; schedule(); } -function markHydrated(seed) { - if (seed) parts = { ...seed, ...parts }; +function markHydrated(authoritative) { + parts = authoritative ? { projects: authoritative.projects, view: authoritative.view } : {}; hydrated = true; } function staysReadOnly() { @@ -22108,7 +22111,12 @@ function WorktableSection(props) { (0, import_react3.useEffect)(() => { let cancelled = false; void (async () => { - const res = await loadHostState(); + let localAt = 0; + try { + localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? "0") || 0; + } catch { + } + const res = await loadHostState(localAt); if (cancelled) return; if (!res.reachable) { staysReadOnly(); @@ -22116,18 +22124,14 @@ function WorktableSection(props) { return; } const host = res.state; - let localAt = 0; - try { - localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? "0") || 0; - } catch { - } const hostAt = typeof host?.updatedAt === "number" ? host.updatedAt : 0; if (host && hostAt > localAt) { if (host.projects) persistProjects(normalizeProjects(host.projects)); if (host.view) persistView(normalizeView(host.view)); + markHydrated({ projects: host.projects, view: host.view }); + } else { + markHydrated(null); } - markHydrated(host ? { projects: host.projects, view: host.view } : {}); - if (hostAt <= localAt) flushHostSave(); })(); const flush2 = () => flushHostSave(); window.addEventListener("pagehide", flush2); diff --git a/01_content/lib/index.js b/01_content/lib/index.js index 7a68294..95e2d72 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -225,7 +225,7 @@ function resolveDshHomeSafe() { cachedDshHome = baseDshHome(); return cachedDshHome; } -var PLUGIN_VERSION = false ? "dev" : "0.5.2-local.3"; +var PLUGIN_VERSION = false ? "dev" : "0.5.2-local.6"; var name = "dsh-worktable"; var inject = ["webServer", "sessions"]; var HEALTH_PATH = "/api/worktable/health"; @@ -483,12 +483,24 @@ function apply(ctx) { } const previews = createPreviewFiles(); ctx.effect(() => () => previews.clear(), "dsh-worktable: preview capabilities"); + const stateLog = (line) => { + void (async () => { + try { + const fsx = await import("node:fs/promises"); + const dir = pathResolve(resolveDshHomeSafe(), "logs"); + await fsx.mkdir(dir, { recursive: true }); + await fsx.appendFile(pathResolve(dir, "worktable-state.log"), (/* @__PURE__ */ new Date()).toISOString() + " " + line + "\n", "utf8"); + } catch { + } + })(); + }; const register = (route, publicAsset = false) => ctx.effect(() => webServer.register({ ...route, handler: async (req, res) => { if (!publicAsset && route.path !== HEALTH_PATH) { const rejected = await requestRejection(ctx, req); if (rejected !== void 0) { + if (route.path === "/api/worktable/state") stateLog("REJECT " + req.method + " -> " + rejected); json(res, rejected, { error: rejectionText(rejected) }); return; } @@ -841,12 +853,15 @@ function apply(ctx) { try { const raw = await readFile(file, "utf8"); const text = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw; - json(res, 200, { ok: true, path: file, state: JSON.parse(text) }); + const parsed = JSON.parse(text); + json(res, 200, { ok: true, path: file, state: parsed }); } catch (err) { if (err?.code === "ENOENT") { + stateLog("GET empty (no file)"); json(res, 200, { ok: true, path: file, state: null }); return; } + stateLog("GET error " + String(err)); json(res, 500, { error: String(err) }); } return; @@ -859,6 +874,7 @@ function apply(ctx) { const body = await readJsonBody(req); const incoming = body && typeof body.state === "object" && body.state !== null ? body.state : null; if (!incoming) { + stateLog("POST 400 missing state"); json(res, 400, { error: "missing state" }); return; } @@ -870,6 +886,11 @@ function apply(ctx) { } const state = previous && typeof previous === "object" && !Array.isArray(previous) ? { ...previous, ...incoming } : incoming; const text = JSON.stringify(state); + const inN = incoming?.projects?.layouts?.length ?? 0; + const prevN = previous?.projects?.layouts?.length ?? 0; + if (inN < prevN) { + stateLog("WARN projects shrink " + prevN + " -> " + inN + " (keys=" + Object.keys(incoming).join("+") + ")"); + } if (text.length > 20 * 1024 * 1024) { json(res, 413, { error: "state too large" }); return; diff --git a/01_content/package.json b/01_content/package.json index 6c7fef5..cea3ed0 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)", - "version": "0.5.2-local.3", + "version": "0.5.2-local.6", "repository": { "type": "git", "url": "https://github.com/Aisland-SJL/dsh-worktable.git", diff --git a/01_content/src/client/hostState.ts b/01_content/src/client/hostState.ts index a0b22d7..db9e5e8 100644 --- a/01_content/src/client/hostState.ts +++ b/01_content/src/client/hostState.ts @@ -35,11 +35,14 @@ const RETRY_DELAYS = [0, 300, 700, 1200, 2000, 3000] * - reachable: true, state: null → 宿主可达但还没有镜像(首次使用),可以写 * - reachable: false → 读不到,本次会话禁止写 */ -export async function loadHostState(): Promise { +export async function loadHostState(localAt = 0): Promise { for (const delay of RETRY_DELAYS) { if (delay) await new Promise((r) => setTimeout(r, delay)) try { - const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } }) + const r = await fetch(ENDPOINT + '?localAt=' + encodeURIComponent(String(localAt)), { + cache: 'no-store', + headers: { accept: 'application/json' }, + }) if (!r.ok) continue const j: any = await r.json() if (j && typeof j === 'object' && j.state && typeof j.state === 'object') { @@ -103,10 +106,11 @@ export function queueHostSave(patch: HostStateParts): void { /** * 宿主镜像已成功读到,放行写入。 - * 传入读到的内容,让之后的半截写入也能带上另一半。 + * 传入权威内容时**整体替换**待写队列 —— 首屏自愈等早于恢复发生的写入必须作废, + * 否则那半截空值会把刚恢复好的项目盖掉(真实事故)。 */ -export function markHydrated(seed?: HostStateParts): void { - if (seed) parts = { ...seed, ...parts } +export function markHydrated(authoritative?: HostStateParts | null): void { + parts = authoritative ? { projects: authoritative.projects, view: authoritative.view } : {} hydrated = true } diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 9ac3433..61137de 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -1433,7 +1433,9 @@ function WorktableSection(props: any) { useEffect(() => { let cancelled = false void (async () => { - const res = await loadHostState() + let localAt = 0 + try { localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? '0') || 0 } catch {} + const res = await loadHostState(localAt) if (cancelled) return if (!res.reachable) { // 读不到就不写:宁可本次只在本地,也不拿空状态覆盖宿主镜像 @@ -1442,15 +1444,16 @@ function WorktableSection(props: any) { return } const host = res.state - let localAt = 0 - try { localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? '0') || 0 } catch {} const hostAt = typeof host?.updatedAt === 'number' ? host.updatedAt : 0 if (host && hostAt > localAt) { if (host.projects) persistProjects(normalizeProjects(host.projects)) if (host.view) persistView(normalizeView(host.view)) + // 以宿主为准:作废首屏自愈等早于恢复的待写内容,避免半截空值回写 + markHydrated({ projects: host.projects, view: host.view }) + } else { + // 本地更新或宿主还没有镜像:什么都不发,等用户真的改动再写 + markHydrated(null) } - markHydrated(host ? { projects: host.projects, view: host.view } : {}) - if (hostAt <= localAt) flushHostSave() })() const flush = () => flushHostSave() window.addEventListener('pagehide', flush) diff --git a/01_content/src/index.ts b/01_content/src/index.ts index c35c153..7dea032 100644 --- a/01_content/src/index.ts +++ b/01_content/src/index.ts @@ -282,13 +282,27 @@ export function apply(ctx: Context) { const previews = createPreviewFiles() ctx.effect(() => () => previews.clear(), 'dsh-worktable: preview capabilities') + /** 诊断日志:状态路由到底有没有被调用、被什么挡住(排查重启丢数据用,稳定后可删) */ + const stateLog = (line: string) => { + void (async () => { + try { + const fsx = await import('node:fs/promises') + const dir = pathResolve(resolveDshHomeSafe(), 'logs') + await fsx.mkdir(dir, { recursive: true }) + await fsx.appendFile(pathResolve(dir, 'worktable-state.log'), new Date().toISOString() + ' ' + line + '\n', 'utf8') + } catch {} + })() + } // Direct WebServer routes do NOT inherit the host's /api authentication route. const register = (route: any, publicAsset = false) => ctx.effect(() => webServer.register({ ...route, handler: async (req: any, res: any) => { if (!publicAsset && route.path !== HEALTH_PATH) { const rejected = await requestRejection(ctx, req) - if (rejected !== undefined) { json(res, rejected, { error: rejectionText(rejected) }); return } + if (rejected !== undefined) { + if (route.path === '/api/worktable/state') stateLog('REJECT ' + req.method + ' -> ' + rejected) + json(res, rejected, { error: rejectionText(rejected) }); return + } } return route.handler(req, res) }, @@ -561,9 +575,11 @@ export function apply(ctx: Context) { try { const raw = await readFile(file, 'utf8') const text = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw - json(res, 200, { ok: true, path: file, state: JSON.parse(text) }) + const parsed: any = JSON.parse(text) + json(res, 200, { ok: true, path: file, state: parsed }) } catch (err: any) { - if (err?.code === 'ENOENT') { json(res, 200, { ok: true, path: file, state: null }); return } + if (err?.code === 'ENOENT') { stateLog('GET empty (no file)'); json(res, 200, { ok: true, path: file, state: null }); return } + stateLog('GET error ' + String(err)) json(res, 500, { error: String(err) }) } return @@ -572,7 +588,7 @@ export function apply(ctx: Context) { try { const body = await readJsonBody(req) const incoming = body && typeof body.state === 'object' && body.state !== null ? body.state : null - if (!incoming) { json(res, 400, { error: 'missing state' }); return } + if (!incoming) { stateLog('POST 400 missing state'); json(res, 400, { error: 'missing state' }); return } // 按 key 合并:客户端只带 view 的半截写入不能把 projects 抹掉 let previous: any = null try { @@ -583,6 +599,12 @@ export function apply(ctx: Context) { ? { ...previous, ...incoming } : incoming const text = JSON.stringify(state) + // 只在「项目变少」时记一行:正常保存不写日志,避免文件无限增长 + const inN = incoming?.projects?.layouts?.length ?? 0 + const prevN = previous?.projects?.layouts?.length ?? 0 + if (inN < prevN) { + stateLog('WARN projects shrink ' + prevN + ' -> ' + inN + ' (keys=' + Object.keys(incoming).join('+') + ')') + } if (text.length > 20 * 1024 * 1024) { json(res, 413, { error: 'state too large' }); return } const fsx = await import('node:fs/promises') await fsx.mkdir(dirname(file), { recursive: true }) diff --git a/PATCH.md b/PATCH.md index 33a435f..4a960f9 100644 --- a/PATCH.md +++ b/PATCH.md @@ -1,6 +1,6 @@ # 本地补丁:工作台状态镜像到宿主侧 -基于上游 `Aisland-SJL/dsh-worktable` **v0.5.1**,本地版本号 `0.5.2-local.3`。 +基于上游 `Aisland-SJL/dsh-worktable` **v0.5.1**,本地版本号 `0.5.2-local.6`。 ## 为什么打这个补丁 @@ -23,7 +23,7 @@ - 挂载时拉宿主镜像,只有 `updatedAt > 本地 savedAt` 才回填,避免旧镜像盖掉刚改的内容 - `pagehide` / `beforeunload` 立即冲刷 -## 两条数据安全约束(0.5.2-local.3 补的,来自真实事故) +## 两条数据安全约束(0.5.2-local.6 补的,来自真实事故) 事故:拖了一下面板(只改了 `view`),客户端 POST 的半截状态把整个文件替换,`projects` 被抹掉; 紧接着重启,客户端读宿主失败又用空状态覆盖了一次。 @@ -32,8 +32,12 @@ 2. **读不到就不写**:`loadHostState()` 带退避重试(0/300/700/1200/2000/3000ms)。 返回 `{ reachable: false }` 时调用 `staysReadOnly()`,**整场会话都不写镜像** —— 宁可这次只在本地,也不拿空状态覆盖。 -3. hydration 成功后 `markHydrated({ projects, view })` 把读到的内容留在内存, - 后续半截写入也会带上另一半。 +3. hydration 成功后 `markHydrated(权威内容)` **整体替换**待写队列。 + 事故根因:首屏「控制室自愈」effect 早于异步恢复跑完,把空项目塞进队列; + 旧实现只是合并队列,那份空值留了下来,恢复后 0.5 秒就被写回。必须整体替换。 +4. 宿主镜像不比本地新时**不主动落盘**,等用户真的改动再写。 + +`logs/worktable-state.log` 只在异常时记一行(鉴权被拒 / 项目数量变少),正常保存不写,可长期保留。 改动这三条时注意:`hydrated` 为 false 期间 `queueHostSave` 只累积不发送,这是首屏默认值的闸门,别去掉。 @@ -80,4 +84,4 @@ dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/r | --- | --- | | 0.5.2-local.1 | 状态镜像首版;只声明兼容到 0.2.1-rc.5 | | 0.5.2-local.2 | 兼容声明加入 DSH 0.2.2-alpha.2(宿主升级后安装被拒) | -| 0.5.2-local.3 | 修数据安全:写入按 key 合并 + 读不到不写,修复空状态覆盖镜像 | +| 0.5.2-local.6 | 修数据安全:写入按 key 合并 + 读不到不写,修复空状态覆盖镜像 | diff --git a/README.md b/README.md index d1f1c2f..01a6f73 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > ## 📦 本仓库是内部补丁分发仓,不是上游源码仓 > -> 上游: | 本仓 = 上游 **v0.5.1** + 桌面端状态持久化补丁(`0.5.2-local.3`)。 +> 上游: | 本仓 = 上游 **v0.5.1** + 桌面端状态持久化补丁(`0.5.2-local.6`)。 > 下面从「Screenshots」开始的全部内容是上游 README 原文;本次改动说明见 [PATCH.md](PATCH.md)。 ### 要不要装 @@ -16,16 +16,16 @@ ### 安装 -当前版本 **v0.5.2-local.3**: +当前版本 **v0.5.2-local.6**: ```bash -dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/releases/download/v0.5.2-local.3/dsh-worktable.tgz" +dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/releases/download/v0.5.2-local.6/dsh-worktable.tgz" ``` - **桌面端:先完全退出 Slark,再执行命令**,装完手动重开;不要在宿主运行时安装。 - `--profile web` 按你自己的 profile 名字改。 -- 资产 `dsh-worktable.tgz`,478216 字节,sha256: - `a2414c66282686648d6c1b8aecada2f1e420fb20afdf1d47715eaa9cc66fe3db` +- 资产 `dsh-worktable.tgz`,478462 字节,sha256: + `421bca6e48689cf96af38a6703c4b1c31435b5e476209c575c6faccce650d07a` (下载后可 `Get-FileHash dsh-worktable.tgz -Algorithm SHA256` 核对) - 声明兼容的 DSH 版本:`0.2.0-rc.2` / `0.2.1-rc.3` / `0.2.1-rc.5` / `0.2.2-alpha.2`。 宿主不在这个列表里时,安装会被兼容性检查直接拒绝并回滚。 @@ -44,13 +44,13 @@ dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/r - **覆盖**:项目、布局、绑定、项目文件夹、视图与外观设置 → 镜像到 `/worktable-state.json` - **不覆盖**:图库媒体(照片仍存在 IndexedDB),换端口照样丢 - 状态文件是宿主侧的,跨重启、跨端口稳定;localStorage 仍保留为第一落点 -- **不丢数据的两条保险**(0.5.2-local.3 起): +- **不丢数据的两条保险**(0.5.2-local.6 起): - 写入按 key 合并 —— 只改了外观时,`projects` 不会被半截写入抹掉 - 启动时读不到宿主状态就整场不写 —— 宁可这次只在本地,也不拿空状态覆盖镜像 ### 升级与回退 -- 装上 `0.5.2-local.3` 后,插件不会提示升级到 0.5.1。 +- 装上 `0.5.2-local.6` 后,插件不会提示升级到 0.5.1。 - 上游发布 0.5.2 及以上时插件会提示升级;**一旦升级就回到没有补丁的状态**,重启又会丢数据。新版本需要把补丁重新合并(改动只有三处,见 [PATCH.md](PATCH.md))。 - 回退官方版: ```bash