fix: 状态镜像防冲掉——服务端按 key 合并,客户端读不到宿主就不写
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
|
||||
"version": "0.5.2-local.2",
|
||||
"version": "0.5.2-local.3",
|
||||
"entry": {
|
||||
"name": "dsh-worktable",
|
||||
"inject": []
|
||||
|
||||
+28
-11
@@ -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.2";
|
||||
var LOCAL_VERSION = false ? "dev" : "0.5.2-local.3";
|
||||
var UPDATE_REPO = "Aisland-SJL/dsh-worktable";
|
||||
var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1";
|
||||
var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1";
|
||||
@@ -20735,15 +20735,22 @@ 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() {
|
||||
try {
|
||||
const r = await fetch(ENDPOINT, { cache: "no-store", headers: { accept: "application/json" } });
|
||||
if (!r.ok) return null;
|
||||
const j = await r.json();
|
||||
return j && typeof j === "object" && j.state && typeof j.state === "object" ? j.state : null;
|
||||
} catch {
|
||||
return null;
|
||||
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" } });
|
||||
if (!r.ok) continue;
|
||||
const j = await r.json();
|
||||
if (j && typeof j === "object" && j.state && typeof j.state === "object") {
|
||||
return { reachable: true, state: j.state };
|
||||
}
|
||||
return { reachable: true, state: null };
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return { reachable: false };
|
||||
}
|
||||
var parts = {};
|
||||
var timer = null;
|
||||
@@ -20785,9 +20792,13 @@ function queueHostSave(patch) {
|
||||
if (!hydrated) return;
|
||||
schedule();
|
||||
}
|
||||
function markHydrated() {
|
||||
function markHydrated(seed) {
|
||||
if (seed) parts = { ...seed, ...parts };
|
||||
hydrated = true;
|
||||
}
|
||||
function staysReadOnly() {
|
||||
hydrated = false;
|
||||
}
|
||||
function flushHostSave() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
@@ -22097,8 +22108,14 @@ function WorktableSection(props) {
|
||||
(0, import_react3.useEffect)(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const host = await loadHostState();
|
||||
const res = await loadHostState();
|
||||
if (cancelled) return;
|
||||
if (!res.reachable) {
|
||||
staysReadOnly();
|
||||
console.warn("[dsh-worktable] \u5BBF\u4E3B\u72B6\u6001\u6682\u4E0D\u53EF\u8BFB\uFF0C\u672C\u6B21\u4F1A\u8BDD\u4E0D\u5199\u955C\u50CF");
|
||||
return;
|
||||
}
|
||||
const host = res.state;
|
||||
let localAt = 0;
|
||||
try {
|
||||
localAt = Number(localStorage.getItem(SAVED_AT_KEY) ?? "0") || 0;
|
||||
@@ -22109,7 +22126,7 @@ function WorktableSection(props) {
|
||||
if (host.projects) persistProjects(normalizeProjects(host.projects));
|
||||
if (host.view) persistView(normalizeView(host.view));
|
||||
}
|
||||
markHydrated();
|
||||
markHydrated(host ? { projects: host.projects, view: host.view } : {});
|
||||
if (hostAt <= localAt) flushHostSave();
|
||||
})();
|
||||
const flush2 = () => flushHostSave();
|
||||
|
||||
+10
-3
@@ -225,7 +225,7 @@ function resolveDshHomeSafe() {
|
||||
cachedDshHome = baseDshHome();
|
||||
return cachedDshHome;
|
||||
}
|
||||
var PLUGIN_VERSION = false ? "dev" : "0.5.2-local.2";
|
||||
var PLUGIN_VERSION = false ? "dev" : "0.5.2-local.3";
|
||||
var name = "dsh-worktable";
|
||||
var inject = ["webServer", "sessions"];
|
||||
var HEALTH_PATH = "/api/worktable/health";
|
||||
@@ -857,11 +857,18 @@ function apply(ctx) {
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const state = body && typeof body.state === "object" && body.state !== null ? body.state : null;
|
||||
if (!state) {
|
||||
const incoming = body && typeof body.state === "object" && body.state !== null ? body.state : null;
|
||||
if (!incoming) {
|
||||
json(res, 400, { error: "missing state" });
|
||||
return;
|
||||
}
|
||||
let previous = null;
|
||||
try {
|
||||
const raw = await readFile(file, "utf8");
|
||||
previous = JSON.parse(raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw);
|
||||
} catch {
|
||||
}
|
||||
const state = previous && typeof previous === "object" && !Array.isArray(previous) ? { ...previous, ...incoming } : incoming;
|
||||
const text = JSON.stringify(state);
|
||||
if (text.length > 20 * 1024 * 1024) {
|
||||
json(res, 413, { error: "state too large" });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dsh-worktable",
|
||||
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
|
||||
"version": "0.5.2-local.2",
|
||||
"version": "0.5.2-local.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Aisland-SJL/dsh-worktable.git",
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
* 做法:把「项目 + 视图」这份 JSON 镜像到宿主侧文件(/api/worktable/state),
|
||||
* 启动时若宿主侧比本地新,就用宿主侧恢复。localStorage 仍是第一落点(同步、快),
|
||||
* 宿主文件是跨重启的兜底。
|
||||
*
|
||||
* 两条安全约束(都来自一次真实事故:空状态把宿主文件冲掉):
|
||||
* 1. 读不到宿主状态就**不写**——宁可这次会话只在本地,也不拿空值覆盖镜像。
|
||||
* 2. POST 始终带上完整的 parts(projects 与 view 都在内存里各留一份),
|
||||
* 服务端再做一次按 key 合并,半截写入不会丢另一半。
|
||||
*/
|
||||
|
||||
const ENDPOINT = '/api/worktable/state'
|
||||
@@ -17,16 +22,35 @@ export type HostStateParts = { projects?: unknown; view?: unknown }
|
||||
/** 宿主镜像里可能带的写入时间戳(毫秒) */
|
||||
export type HostState = HostStateParts & { updatedAt?: number }
|
||||
|
||||
/** 拉取宿主侧镜像;没有/不可用都返回 null(不抛错,不影响工作台本身) */
|
||||
export async function loadHostState(): Promise<HostState | null> {
|
||||
try {
|
||||
const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } })
|
||||
if (!r.ok) return null
|
||||
const j: any = await r.json()
|
||||
return j && typeof j === 'object' && j.state && typeof j.state === 'object' ? (j.state as HostState) : null
|
||||
} catch {
|
||||
return null
|
||||
/** reachable=false 表示宿主这次根本没读到(连接没就绪/路由不可用),不能据此覆盖镜像 */
|
||||
export type HostReadResult =
|
||||
| { reachable: true; state: HostState | null }
|
||||
| { reachable: false }
|
||||
|
||||
const RETRY_DELAYS = [0, 300, 700, 1200, 2000, 3000]
|
||||
|
||||
/**
|
||||
* 拉取宿主侧镜像;不抛错,不影响工作台本身。
|
||||
* 页面刚挂载时宿主连接可能还没就绪,所以这里带退避重试。
|
||||
* - reachable: true, state: null → 宿主可达但还没有镜像(首次使用),可以写
|
||||
* - reachable: false → 读不到,本次会话禁止写
|
||||
*/
|
||||
export async function loadHostState(): Promise<HostReadResult> {
|
||||
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' } })
|
||||
if (!r.ok) continue
|
||||
const j: any = await r.json()
|
||||
if (j && typeof j === 'object' && j.state && typeof j.state === 'object') {
|
||||
return { reachable: true, state: j.state as HostState }
|
||||
}
|
||||
return { reachable: true, state: null }
|
||||
} catch {
|
||||
// 继续重试
|
||||
}
|
||||
}
|
||||
return { reachable: false }
|
||||
}
|
||||
|
||||
/** 累积中的镜像内容:POST 只带最新值,避免每次编辑都发整包 */
|
||||
@@ -34,7 +58,7 @@ let parts: HostStateParts = {}
|
||||
let timer: any = null
|
||||
let inflight = false
|
||||
let dirty = false
|
||||
/** 恢复完成前不写宿主,否则首屏的默认值会把宿主里的旧数据覆盖掉 */
|
||||
/** 读完宿主之前不写宿主:首屏默认值不能覆盖镜像,读失败就整场不写 */
|
||||
let hydrated = false
|
||||
|
||||
function schedule() {
|
||||
@@ -77,11 +101,20 @@ export function queueHostSave(patch: HostStateParts): void {
|
||||
schedule()
|
||||
}
|
||||
|
||||
/** 首次恢复判定结束:之后再放行宿主写入 */
|
||||
export function markHydrated(): void {
|
||||
/**
|
||||
* 宿主镜像已成功读到,放行写入。
|
||||
* 传入读到的内容,让之后的半截写入也能带上另一半。
|
||||
*/
|
||||
export function markHydrated(seed?: HostStateParts): void {
|
||||
if (seed) parts = { ...seed, ...parts }
|
||||
hydrated = true
|
||||
}
|
||||
|
||||
/** 本次会话没有读到宿主:禁止写入,避免空状态覆盖镜像 */
|
||||
export function staysReadOnly(): void {
|
||||
hydrated = false
|
||||
}
|
||||
|
||||
/** 立即冲刷(页面隐藏/关闭前调用,避免丢掉最后一个 400ms 内的改动) */
|
||||
export function flushHostSave(): void {
|
||||
if (timer) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { previewFromEvents, previewFromNodes, readSessionPreview, presetApiOf, m
|
||||
import { photoStore, kindOf } from './photoStore'
|
||||
import { DEFAULT_BG_SVG, b64ToBlob } from './defaultBg'
|
||||
import { WAVE_BG_B64 } from './waveBg'
|
||||
import { loadHostState, queueHostSave, markHydrated, flushHostSave } from './hostState'
|
||||
import { loadHostState, queueHostSave, markHydrated, staysReadOnly, flushHostSave } from './hostState'
|
||||
|
||||
/**
|
||||
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
|
||||
@@ -1433,8 +1433,15 @@ function WorktableSection(props: any) {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const host = await loadHostState()
|
||||
const res = await loadHostState()
|
||||
if (cancelled) return
|
||||
if (!res.reachable) {
|
||||
// 读不到就不写:宁可本次只在本地,也不拿空状态覆盖宿主镜像
|
||||
staysReadOnly()
|
||||
console.warn('[dsh-worktable] 宿主状态暂不可读,本次会话不写镜像')
|
||||
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
|
||||
@@ -1442,7 +1449,7 @@ function WorktableSection(props: any) {
|
||||
if (host.projects) persistProjects(normalizeProjects(host.projects))
|
||||
if (host.view) persistView(normalizeView(host.view))
|
||||
}
|
||||
markHydrated()
|
||||
markHydrated(host ? { projects: host.projects, view: host.view } : {})
|
||||
if (hostAt <= localAt) flushHostSave()
|
||||
})()
|
||||
const flush = () => flushHostSave()
|
||||
|
||||
+11
-2
@@ -571,8 +571,17 @@ export function apply(ctx: Context) {
|
||||
if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return }
|
||||
try {
|
||||
const body = await readJsonBody(req)
|
||||
const state = body && typeof body.state === 'object' && body.state !== null ? body.state : null
|
||||
if (!state) { json(res, 400, { error: 'missing state' }); return }
|
||||
const incoming = body && typeof body.state === 'object' && body.state !== null ? body.state : null
|
||||
if (!incoming) { json(res, 400, { error: 'missing state' }); return }
|
||||
// 按 key 合并:客户端只带 view 的半截写入不能把 projects 抹掉
|
||||
let previous: any = null
|
||||
try {
|
||||
const raw = await readFile(file, 'utf8')
|
||||
previous = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw)
|
||||
} catch {}
|
||||
const state = previous && typeof previous === 'object' && !Array.isArray(previous)
|
||||
? { ...previous, ...incoming }
|
||||
: incoming
|
||||
const text = JSON.stringify(state)
|
||||
if (text.length > 20 * 1024 * 1024) { json(res, 413, { error: 'state too large' }); return }
|
||||
const fsx = await import('node:fs/promises')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> ## 📦 本仓库是内部补丁分发仓,不是上游源码仓
|
||||
>
|
||||
> 上游:<https://github.com/Aisland-SJL/dsh-worktable> | 本仓 = 上游 **v0.5.1** + 桌面端状态持久化补丁(`0.5.2-local.2`)。
|
||||
> 上游:<https://github.com/Aisland-SJL/dsh-worktable> | 本仓 = 上游 **v0.5.1** + 桌面端状态持久化补丁(`0.5.2-local.3`)。
|
||||
> 下面从「Screenshots」开始的全部内容是上游 README 原文;本次改动说明见 [PATCH.md](PATCH.md)。
|
||||
|
||||
### 要不要装
|
||||
@@ -16,16 +16,16 @@
|
||||
|
||||
### 安装
|
||||
|
||||
当前版本 **v0.5.2-local.2**:
|
||||
当前版本 **v0.5.2-local.3**:
|
||||
|
||||
```bash
|
||||
dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/releases/download/v0.5.2-local.2/dsh-worktable.tgz"
|
||||
dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/releases/download/v0.5.2-local.3/dsh-worktable.tgz"
|
||||
```
|
||||
|
||||
- **桌面端:先完全退出 Slark,再执行命令**,装完手动重开;不要在宿主运行时安装。
|
||||
- `--profile web` 按你自己的 profile 名字改。
|
||||
- 资产 `dsh-worktable.tgz`,477893 字节,sha256:
|
||||
`2488f4bda8556f5b8bb5b1b568ad92f6400f097b9821d933159c2e8ea34cb057`
|
||||
- 资产 `dsh-worktable.tgz`,478216 字节,sha256:
|
||||
`a2414c66282686648d6c1b8aecada2f1e420fb20afdf1d47715eaa9cc66fe3db`
|
||||
(下载后可 `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,10 +44,13 @@ dsh plugin --profile web add "https://gitea.ququclean.com/xiaoqu/dsh-worktable/r
|
||||
- **覆盖**:项目、布局、绑定、项目文件夹、视图与外观设置 → 镜像到 `<DSH_HOME>/worktable-state.json`
|
||||
- **不覆盖**:图库媒体(照片仍存在 IndexedDB),换端口照样丢
|
||||
- 状态文件是宿主侧的,跨重启、跨端口稳定;localStorage 仍保留为第一落点
|
||||
- **不丢数据的两条保险**(0.5.2-local.3 起):
|
||||
- 写入按 key 合并 —— 只改了外观时,`projects` 不会被半截写入抹掉
|
||||
- 启动时读不到宿主状态就整场不写 —— 宁可这次只在本地,也不拿空状态覆盖镜像
|
||||
|
||||
### 升级与回退
|
||||
|
||||
- 装上 `0.5.2-local.2` 后,插件不会提示升级到 0.5.1。
|
||||
- 装上 `0.5.2-local.3` 后,插件不会提示升级到 0.5.1。
|
||||
- 上游发布 0.5.2 及以上时插件会提示升级;**一旦升级就回到没有补丁的状态**,重启又会丢数据。新版本需要把补丁重新合并(改动只有三处,见 [PATCH.md](PATCH.md))。
|
||||
- 回退官方版:
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user