From 9647f11bf90265e645198e933bc228ad8e131c23 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 18 Jul 2026 17:17:58 +0200 Subject: [PATCH] Fix plugin updates with locked data directories --- CHANGELOG.md | 5 ++ TODO.md | 2 + docs/updates.md | 5 ++ knowledge/core/lumi-core.md | 2 +- package-lock.json | 4 +- package.json | 3 +- release-index.json | 30 ++++++++ scripts/build-core-repair-patch.js | 68 ------------------- scripts/verify-core-repair-patch.js | 62 ----------------- .../verify-plugin-update-preserves-data.js | 15 +++- scripts/verify-release-metadata.js | 6 +- scripts/verify-update-system.js | 5 +- src/services/update-manager.js | 58 ++++++++++++++++ update-manifest.json | 16 ++++- 14 files changed, 137 insertions(+), 144 deletions(-) delete mode 100644 scripts/build-core-repair-patch.js delete mode 100644 scripts/verify-core-repair-patch.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0dfff..be8d9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Lumi changelog +## 0.2.6 + +- Fixed plugin updates on Windows and network shares when a running plugin keeps its preserved `data` directory open. +- Plugin updates and automatic restores now leave preserved data in place while transactionally replacing only plugin code, with rollback if a code-file replacement fails. + ## 0.2.5 - Fixed diagnostics behind local/private reverse proxies by accepting forwarded HTTPS only from loopback, RFC1918/link-local IPv4, or private/link-local IPv6 proxy addresses. diff --git a/TODO.md b/TODO.md index e8b375a..c0dccb6 100644 --- a/TODO.md +++ b/TODO.md @@ -678,6 +678,8 @@ This section is for Lumi chat answer feedback and AI Improvement Center work, no ## Done - 2026-07-18: Fixed private reverse-proxy HTTPS recognition for production diagnostics in core 0.2.5 without globally trusting client forwarding headers, and taught the local client to consume the repository's env-style `.secrets` file and short diagnostic variable names directly. +- 2026-07-18: Fixed Windows/network-share plugin updates in core 0.2.6 by leaving live preserved plugin data in place and transactionally replacing only code, including automatic code rollback coverage. +- 2026-07-18: Retired the one-off core repair ZIP build scripts after repository updates proved reliable; core 0.2.6 is distributed through the normal immutable Git release flow. - 2026-07-18: Updated production diagnostics examples in core 0.2.4 to derive the full endpoint and client base URL from the administrator's current Lumi request instead of showing a placeholder hostname. - 2026-07-18: Fixed the shared submit-action resolver in core 0.2.3: ordinary buttons now inherit their parent form endpoint unless they explicitly declare `formaction`, restoring timed diagnostics-key creation and preventing async update actions from posting back to the Updates page. - 2026-07-18: Added production-stage plugin update diagnostics in core 0.2.2: selected plugin source is verified before snapshotting, failures record their exact stage and target in update state, and the affected plugin row displays the server error directly. diff --git a/docs/updates.md b/docs/updates.md index d1458c2..67a75ee 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -76,6 +76,11 @@ Before a core, plugin, repo, or ZIP update, Lumi creates a snapshot under - major-crossing flag - compressed storage size +For plugin updates, Lumi leaves the plugin's `data` directory in its existing +location and transactionally replaces only code files. This allows running +Windows services and network shares to retain open AI model/runtime handles +without blocking the update or its automatic rollback. + New snapshots contain only the replaceable rollback payload: compressed core or plugin code and a compressed SQLite database backup. Preserved local data is not duplicated into snapshots. In particular, LLM model files, managed AI diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 5138ed8..0a4ed36 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,7 +14,7 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.2.5 +Version: 0.2.6 ## Routes - GET /api/events - POST /api/destructive-confirmations diff --git a/package-lock.json b/package-lock.json index 2dbc095..bfa1406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.2.5", + "version": "0.2.6", "dependencies": { "adm-zip": "^0.5.12", "better-sqlite3": "^11.5.0", diff --git a/package.json b/package.json index 9caa8a5..cd2dacc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.5", + "version": "0.2.6", "private": true, "type": "commonjs", "scripts": { @@ -13,7 +13,6 @@ "verify:updates": "node scripts/verify-release-metadata.js && node scripts/verify-update-system.js", "verify:diagnostics": "node scripts/verify-production-diagnostics.js", "diagnostics:production": "node scripts/production-diagnostics-client.js", - "build:repair-patch": "node scripts/build-core-repair-patch.js && node scripts/verify-core-repair-patch.js", "verify:web-auth": "node scripts/verify-web-auth.js", "verify:destructive-actions": "node scripts/verify-destructive-actions.js", "verify:overlays": "node scripts/verify-overlays.js", diff --git a/release-index.json b/release-index.json index 473ea50..275312b 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,36 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.2.6", + "ref": "refs/tags/v0.2.6", + "released_at": "2026-07-18", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Replaces plugin code without renaming preserved plugin data, allowing updates while Windows or a network share holds runtime/model files open.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.2", + "moderation": "0.1.5", + "okf": "0.1.1", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.2.5", "ref": "refs/tags/v0.2.5", diff --git a/scripts/build-core-repair-patch.js b/scripts/build-core-repair-patch.js deleted file mode 100644 index 7d239bd..0000000 --- a/scripts/build-core-repair-patch.js +++ /dev/null @@ -1,68 +0,0 @@ -const crypto = require("crypto"); -const fs = require("fs"); -const path = require("path"); -const AdmZip = require("adm-zip"); - -const root = path.join(__dirname, ".."); -const destination = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.5-repair.zip"); -const files = [ - "CHANGELOG.md", - "README.md", - "package.json", - "package-lock.json", - "release-index.json", - "run.js", - "update-manifest.json", - "docs/updates.md", - "docs/production-diagnostics.md", - "docs/update-audit-0.2.0.md", - "knowledge/core/lumi-core.md", - "scripts/verify-release-metadata.js", - "scripts/verify-all.js", - "scripts/build-core-repair-patch.js", - "scripts/verify-core-repair-patch.js", - "scripts/verify-update-system.js", - "scripts/verify-production-diagnostics.js", - "scripts/production-diagnostics-client.js", - "scripts/verify-webui.js", - "src/services/dependency-manager.js", - "src/services/overlay-connectors.js", - "src/services/repo-update.js", - "src/services/production-diagnostics.js", - "src/services/settings.js", - "src/services/update-index.js", - "src/services/update-manager.js", - "src/services/update-repository.js", - "src/web/server.js", - "src/web/public/app.js", - "src/web/views/admin-updates.ejs", - "src/web/views/admin-diagnostics.ejs" -]; - -const hashes = {}; -const zip = new AdmZip(); -for (const relativePath of files) { - const filePath = path.join(root, relativePath); - if (!fs.statSync(filePath, { throwIfNoEntry: false })?.isFile()) { - throw new Error(`Repair patch input is missing: ${relativePath}`); - } - const contents = fs.readFileSync(filePath); - hashes[relativePath] = crypto.createHash("sha256").update(contents).digest("hex"); - zip.addFile(relativePath.replace(/\\/g, "/"), contents); -} - -const manifest = { - schema_version: 1, - name: "Lumi core 0.2.5 repair", - target: "core", - from_versions: ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.5"], - to_version: "0.2.5", - data_policy: "preserve", - dependency_policy: "sync_on_restart", - created_at: new Date().toISOString(), - files: hashes -}; -zip.addFile("patch-manifest.json", Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)); -fs.mkdirSync(path.dirname(destination), { recursive: true }); -zip.writeZip(destination); -console.log(destination); diff --git a/scripts/verify-core-repair-patch.js b/scripts/verify-core-repair-patch.js deleted file mode 100644 index bdb73e4..0000000 --- a/scripts/verify-core-repair-patch.js +++ /dev/null @@ -1,62 +0,0 @@ -const assert = require("assert"); -const crypto = require("crypto"); -const fs = require("fs"); -const os = require("os"); -const path = require("path"); -const AdmZip = require("adm-zip"); -const { verifyPatchPackage } = require("../src/services/update-manager"); - -const root = path.join(__dirname, ".."); -const archivePath = path.join(root, "dist", "lumi-core-1.2.0-to-0.2.5-repair.zip"); -assert.equal(fs.existsSync(archivePath), true, "build the repair patch first"); -const zip = new AdmZip(archivePath); -const entries = zip.getEntries().filter((entry) => !entry.isDirectory); -const names = new Set(entries.map((entry) => entry.entryName.replace(/\\/g, "/"))); -assert.equal(names.has("patch-manifest.json"), true); -const manifest = JSON.parse(zip.readAsText("patch-manifest.json")); -assert.equal(manifest.target, "core"); -assert.equal(manifest.to_version, "0.2.5"); -assert.equal(manifest.data_policy, "preserve"); -assert.deepEqual(manifest.from_versions, ["0.1.9", "1.2.0", "0.2.0", "0.2.1", "0.2.2", "0.2.3", "0.2.4", "0.2.5"]); - -const forbidden = /^(?:data|plugins|node_modules|config|storage|uploads|logs|database|databases|knowledge\/(?:community|corrections))(?:\/|$)|^\.env(?:\.|$)|^\.secrets$/; -for (const entry of entries) { - assert.doesNotMatch(entry.entryName, forbidden, `repair includes preserved data path ${entry.entryName}`); -} -for (const [relativePath, expected] of Object.entries(manifest.files)) { - assert.equal(names.has(relativePath), true, `${relativePath} is listed but missing`); - const actual = crypto.createHash("sha256").update(zip.readFile(relativePath)).digest("hex"); - assert.equal(actual, expected, `${relativePath} checksum`); -} -assert.equal(Object.keys(manifest.files).length + 1, entries.length, "every repair file must be checksummed"); -assert.equal(JSON.parse(zip.readAsText("package.json")).version, "0.2.5"); - -const simulation = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repair-simulation-")); -try { - const sentinels = [ - "data/app.db", - "plugins/okf/data/local.db", - "node_modules/local-only/package.json", - "knowledge/community/people.md", - "knowledge/corrections/fix.md", - ".env" - ]; - for (const sentinel of sentinels) { - const target = path.join(simulation, sentinel); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, `preserve:${sentinel}`); - } - zip.extractAllTo(simulation, true); - assert.equal(verifyPatchPackage(simulation).to_version, "0.2.5"); - for (const sentinel of sentinels) { - assert.equal(fs.readFileSync(path.join(simulation, sentinel), "utf8"), `preserve:${sentinel}`); - } - assert.equal(JSON.parse(fs.readFileSync(path.join(simulation, "package.json"), "utf8")).version, "0.2.5"); - assert.equal(fs.existsSync(path.join(simulation, "src", "services", "dependency-manager.js")), true); - assert.equal(fs.existsSync(path.join(simulation, "src", "services", "production-diagnostics.js")), true); - assert.equal(fs.existsSync(path.join(simulation, "src", "web", "views", "admin-diagnostics.ejs")), true); -} finally { - fs.rmSync(simulation, { recursive: true, force: true }); -} - -console.log(`Core repair patch verification passed (${entries.length} files, no preserved data payloads).`); diff --git a/scripts/verify-plugin-update-preserves-data.js b/scripts/verify-plugin-update-preserves-data.js index 65559da..a531584 100644 --- a/scripts/verify-plugin-update-preserves-data.js +++ b/scripts/verify-plugin-update-preserves-data.js @@ -24,7 +24,18 @@ try { fs.ftruncateSync(descriptor, 3 * 1024 * 1024 * 1024); fs.closeSync(descriptor); - replacePluginDirectory(source, target, { preserveData: true }); + const originalRenameForLockedRoot = fs.renameSync; + fs.renameSync = (from, to) => { + if (from === target) { + throw Object.assign(new Error("simulated running plugin data lock"), { code: "EPERM" }); + } + return originalRenameForLockedRoot(from, to); + }; + try { + replacePluginDirectory(source, target, { preserveData: true }); + } finally { + fs.renameSync = originalRenameForLockedRoot; + } assert.equal(fs.readFileSync(path.join(target, "index.js"), "utf8"), "module.exports = 'new';"); assert.equal(fs.existsSync(path.join(target, "stale.js")), false); @@ -37,7 +48,7 @@ try { fs.writeFileSync(path.join(failedSource, "index.js"), "module.exports = 'must-not-land';"); const originalRename = fs.renameSync; fs.renameSync = (from, to) => { - if (String(from).includes(".target.update-") && to === target) { + if (String(from).includes(".target.update-") && path.basename(from) === "index.js") { throw Object.assign(new Error("simulated install lock"), { code: "EPERM" }); } return originalRename(from, to); diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index d6c4312..6425a94 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,8 +4,8 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.2.5"; -const previousCoreVersion = "0.2.4"; +const releaseVersion = "0.2.6"; +const previousCoreVersion = "0.2.5"; const earliestCompatibleCoreVersion = "0.1.9"; const changedPlugins = { "auto-vc": { from: "0.1.5", to: "0.1.6", knowledge: "auto-vc" }, @@ -87,4 +87,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, changedPlugins.lumi_ai.to); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: core 0.2.5 and 11 changed plugin/tool packages."); +console.log("Release metadata verification passed: core 0.2.6 and 11 changed plugin/tool packages."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index fe9071f..9084d05 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -16,7 +16,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,6 +37,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.2.6", ref: "refs/tags/v0.2.6", rollback_safe: true }, { version: "0.2.5", ref: "refs/tags/v0.2.5", rollback_safe: true }, { version: "0.2.4", ref: "refs/tags/v0.2.4", rollback_safe: true }, { version: "0.2.3", ref: "refs/tags/v0.2.3", rollback_safe: true }, @@ -65,7 +66,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.2.5"); +assert.equal(corrected.safe_target_version, "0.2.6"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/update-manager.js b/src/services/update-manager.js index ffea08c..49f6754 100644 --- a/src/services/update-manager.js +++ b/src/services/update-manager.js @@ -693,6 +693,15 @@ function replacePluginDirectory(rootPath, targetDir, options = {}) { try { fs.mkdirSync(staging, { recursive: true }); copyDirectory(rootPath, staging, ignore); + // A running plugin may legitimately keep files below data open. In + // particular, Lumi AI starts llama-server with data/runtime as its working + // directory. Windows and SMB then reject renaming the plugin root even + // though none of the code being updated is locked. Keep the data directory + // in place and transactionally replace only the plugin's code entries. + if (options.preserveData && fs.existsSync(targetDir)) { + replacePluginCodeInPlace(staging, targetDir, backup); + return; + } if (fs.existsSync(targetDir)) { moveDirectory(targetDir, backup, "backup_plugin"); targetMoved = true; @@ -730,6 +739,55 @@ function replacePluginDirectory(rootPath, targetDir, options = {}) { } } +function replacePluginCodeInPlace(staging, targetDir, backup) { + const movedExisting = []; + const installed = []; + let rollbackFailed = false; + fs.mkdirSync(backup, { recursive: true }); + try { + for (const entry of fs.readdirSync(targetDir, { withFileTypes: true })) { + if (entry.name === "data") continue; + moveDirectory( + path.join(targetDir, entry.name), + path.join(backup, entry.name), + "backup_plugin_code" + ); + movedExisting.push(entry.name); + } + for (const entry of fs.readdirSync(staging, { withFileTypes: true })) { + moveDirectory( + path.join(staging, entry.name), + path.join(targetDir, entry.name), + "install_plugin_code" + ); + installed.push(entry.name); + } + fs.rmSync(backup, { recursive: true, force: true }); + } catch (error) { + try { + for (const name of installed.reverse()) { + fs.rmSync(path.join(targetDir, name), { recursive: true, force: true }); + } + for (const name of movedExisting) { + const saved = path.join(backup, name); + if (!fs.existsSync(saved)) continue; + const live = path.join(targetDir, name); + fs.rmSync(live, { recursive: true, force: true }); + moveDirectory(saved, live, "restore_plugin_code"); + } + } catch (restoreError) { + rollbackFailed = true; + error.message = `${error.message} Plugin code rollback also failed: ${restoreError.message}. Backup: ${backup}. Staging: ${staging}`; + } + throw error; + } finally { + if (!rollbackFailed) { + fs.rmSync(staging, { recursive: true, force: true }); + fs.rmSync(backup, { recursive: true, force: true }); + } + } +} + function moveDirectory(source, target, operation) { runFileOperationWithRetries(() => fs.renameSync(source, target), { target, diff --git a/update-manifest.json b/update-manifest.json index 96e4581..60a8871 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.2.5", + "version": "0.2.6", "channel": "stable", "released_at": "2026-07-18", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with private-proxy HTTPS support, and the shared form-action fix required by timed confirmations and repository updates. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.", + "migration_notes": "Includes the 1.2.0 version correction, production plugin-update diagnostics, secured read-only production diagnostics with private-proxy HTTPS support, the shared form-action fix, and Windows/network-share-safe plugin code replacement. Lumi synchronizes runtime dependencies on restart and preserves settings, databases, plugin data, community knowledge, AI models, runtimes, uploads, logs, and secrets.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -85,6 +85,18 @@ ], "rollback_safe": true, "migration_notes": "Recognizes forwarded HTTPS from local/private reverse proxies for secured diagnostics; preserved local data is not replaced." + }, + { + "version": "0.2.6", + "channel": "stable", + "released_at": "2026-07-18", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Updates plugin code without renaming a running plugin's preserved data directory, avoiding Windows and network-share locks; preserved local data is not replaced." } ] }