fix: 恢复成功后整体替换待写队列,修首屏自愈抢跑写空
This commit is contained in:
@@ -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": []
|
||||
|
||||
+17
-13
@@ -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);
|
||||
|
||||
+23
-2
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,11 +35,14 @@ const RETRY_DELAYS = [0, 300, 700, 1200, 2000, 3000]
|
||||
* - reachable: true, state: null → 宿主可达但还没有镜像(首次使用),可以写
|
||||
* - reachable: false → 读不到,本次会话禁止写
|
||||
*/
|
||||
export async function loadHostState(): Promise<HostReadResult> {
|
||||
export async function loadHostState(localAt = 0): 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' } })
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+26
-4
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user