From 6c96cddd4e56685f60e3dc580d7b37a925ab4bf0 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Sat, 5 Sep 2026 16:31:53 +0800 Subject: [PATCH] fix: reconcile stale staged updates --- package.json | 2 +- server/app.ts | 4 +++- server/update-service.ts | 5 +++-- web-next/src/pages/update/UpdatePage.tsx | 14 +++++++++++++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f1ec1d9..55b4fd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.2.5", + "version": "1.2.6", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/server/app.ts b/server/app.ts index dfd6fa8..c7785d4 100644 --- a/server/app.ts +++ b/server/app.ts @@ -58,6 +58,7 @@ import { isNewerVersion } from "./update.js"; import { ACTIVE_UPDATE_STATUSES, checkForUpdate, + currentReleaseVersion, publicCheckFromCache, publicUpdateJob, reconcileOrphanedUpdateJobs, @@ -1016,7 +1017,8 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { // A package may have been downloaded before the host was upgraded by // another path. Never apply a staged archive that is no longer newer // than the release currently serving traffic. - if (!isNewerVersion(config.appVersion, staged.version)) { + const effectiveCurrentVersion = currentReleaseVersion(config) ?? config.appVersion; + if (!isNewerVersion(effectiveCurrentVersion, staged.version)) { const now = Date.now(); database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=?, updated_at=? WHERE id=? AND status='staged'").run("暂存更新已过期,当前版本无需再次升级", now, now, stagedJobId); throw new AppError(409, "UPDATE_NOT_AVAILABLE", "暂存更新已过期,请重新检查更新"); diff --git a/server/update-service.ts b/server/update-service.ts index e956ef8..03473e7 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -439,7 +439,7 @@ function recoveryStateJobId(filePath: string): string | null { } } -function currentReleaseVersion(config: AppConfig): string | null { +export function currentReleaseVersion(config: AppConfig): string | null { try { const target = realpathSync(config.currentLink); const releases = realpathSync(config.releasesDir); @@ -498,7 +498,8 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config: // completes the same release) before returning to this page. Treat the // archive as an expired terminal task so it cannot keep blocking the // queue or appear as an "apply" action for the current version. - if (row.status === "staged" && !isNewerVersion(config.appVersion, row.version) && !matchingFreshRequest && !matchingFreshState) { + const effectiveCurrentVersion = releaseVersion ?? config.appVersion; + if (row.status === "staged" && !isNewerVersion(effectiveCurrentVersion, row.version) && !matchingFreshRequest && !matchingFreshState) { const changed = database.transaction(() => { const result = database.prepare(` UPDATE update_jobs diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 6c902da..6651ee4 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -451,7 +451,19 @@ export default function UpdatePage({ setError(""); try { const result = await api("/api/update/check", { method: "POST" }); - mergeInfo(result, true); + // A check response is authoritative for release availability. It does + // not include a job field, so retain a task only while it is still + // actionable; the status endpoint has already reconciled expired + // staged tasks before this check runs. + setLiveInfo((current) => ({ + ...(current ?? result), + ...result, + job: current?.job && ( + current.job.status === "completed" || + (current.job.status !== "staged" || current.job.operation !== "download") || + (result.latest?.isNewer === true && result.latest.version === current.job.version) + ) ? current.job : null, + })); notify?.("版本检查完成", "info"); } catch (e) { if (e instanceof ApiError && e.status === 429) {