Compare commits

..

1 Commits

Author SHA1 Message Date
Franz Rolfsvaag
5ec18ac0c4 Bootstrap bundled plugins after legacy updates 2026-07-24 14:54:31 +02:00
13 changed files with 272 additions and 22 deletions

View File

@ -1,5 +1,10 @@
# Lumi changelog
## 0.2.27
- Completed bundled-plugin synchronization automatically on the first startup after a legacy core-only update, allowing production hosts on 0.2.25 to receive the Companion transcription and Song Overlay plugins in the same update flow.
- Added a second snapshot-backed plugin synchronization boundary with automatic restoration, exact-release verification, preserved plugin data, and an idempotent completion marker.
## 0.2.26
- Added stable Lumi Companion distribution with a durable per-user installer, shared paired-device authentication, managed OBS integration, preserved identity, user-approved updates, complete legal notices, and matching application/tray branding.

View File

@ -30,9 +30,12 @@ new branch.
## Test an experimental branch
First update production on `main` to the latest stable release through
**Update from repository**. Stable `0.2.26` and newer synchronize core and
bundled plugins in one snapshot-backed transaction, so future switches in either
direction use the same verified workflow.
**Update from repository**. Stable `0.2.27` and newer synchronize core and
bundled plugins in one snapshot-backed transaction. When the update begins on
`0.2.25`, the newly started core automatically completes the exact-release
bundled-plugin phase under a second rollback snapshot before plugins load, with
no repair action required. Future switches in either direction then use the same
verified workflow.
If a `0.2.24` host enters Experimental before taking the stable update, the
guarded compatibility bootstrap completes bundled-plugin synchronization after
the older updater installs the newest `experimental-*` branch; expect a second

View File

@ -155,6 +155,12 @@ cannot leave those layers on different releases. Core code still accesses
optional plugin capabilities through registered framework APIs, and plugins can
be repaired, upgraded, or downgraded independently from their version picker.
Hosts updating from a legacy core-only updater complete the bundled-plugin phase
at the start of the new core, before plugins are loaded. Lumi verifies the same
immutable release ref, takes a second core-and-plugin snapshot, preserves plugin
data and local-only plugins, restores automatically if synchronization fails,
and records an idempotent per-version completion marker.
## Runtime Dependencies
Start Lumi through `run.js` (the `npm start` command does this). Before starting

View File

@ -14,7 +14,7 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.2.26
Version: 0.2.27
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "lumi-bot",
"version": "0.2.26",
"version": "0.2.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.2.26",
"version": "0.2.27",
"dependencies": {
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",

View File

@ -1,6 +1,6 @@
{
"name": "lumi-bot",
"version": "0.2.26",
"version": "0.2.27",
"private": true,
"type": "commonjs",
"scripts": {

View File

@ -2,6 +2,38 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.2.27",
"ref": "refs/tags/v0.2.27",
"released_at": "2026-07-24",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Completes exact-release bundled-plugin synchronization automatically after an update performed by the legacy 0.2.25 core-only updater. The second phase is snapshot-backed, restores automatically on failure, preserves plugin data and local-only plugins, and is idempotent after completion.",
"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.5",
"lumi_transcription": "0.1.0",
"moderation": "0.1.5",
"now_playing": "0.1.2",
"okf": "0.1.2",
"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.26",
"ref": "refs/tags/v0.2.26",

View File

@ -4,9 +4,9 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.2.26";
const previousStableVersion = "0.2.25";
const priorStableVersion = "0.2.24";
const releaseVersion = "0.2.27";
const previousStableVersion = "0.2.26";
const priorStableVersion = "0.2.25";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
lumi_transcription: { version: "0.1.0", knowledge: "lumi-transcription" },
@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.2.26 after 0.2.25 with synchronized Companion plugin metadata.");
console.log("Release metadata verification passed: stable core 0.2.27 after 0.2.26 with synchronized Companion plugin metadata.");

View File

@ -13,7 +13,10 @@ const {
parseDeploymentBranches
} = require("../src/services/update-repository");
const { buildCorePreservePaths } = require("../src/services/update-manager");
const { pendingDeploymentBranch } = require("../src/services/updater");
const {
pendingBundledPluginSync,
pendingDeploymentBranch
} = require("../src/services/updater");
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
@ -21,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "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.deepEqual(releaseVersions, ["0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "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);
@ -34,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.2.26");
assert.equal(currentRelease.version, "0.2.26");
assert.equal(packageVersion, "0.2.27");
assert.equal(currentRelease.version, "0.2.27");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -47,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.2.27", ref: "refs/tags/v0.2.27", rollback_safe: true },
{ version: "0.2.26", ref: "refs/tags/v0.2.26", rollback_safe: true },
{ version: "0.2.25", ref: "refs/tags/v0.2.25", rollback_safe: true },
{ version: "0.2.24", ref: "refs/tags/v0.2.24", rollback_safe: true },
@ -109,6 +113,31 @@ assert.equal(pendingDeploymentBranch({ branch: "experimental-ux" }, "experimenta
assert.equal(pendingDeploymentBranch({ ...completedExperimentalUpdate, deployed_branch: "experimental-ux" }, "experimental", "0.2.25"), null, "completed deployments must not loop");
assert.equal(pendingDeploymentBranch({ ...completedExperimentalUpdate, deployed_branch: "main" }, "experimental", "0.2.25"), "experimental-ux", "stable re-entry must complete the experimental deployment");
assert.equal(pendingDeploymentBranch(completedExperimentalUpdate, "main", "0.2.25"), null, "stable configuration must not bootstrap experimental code");
const completedLegacyCoreUpdate = {
branch: "refs/tags/v0.2.27",
last_update_status: "complete",
last_target_kind: "core",
last_target_version: "0.2.27",
last_snapshot_id: "snapshot-legacy-core"
};
assert.equal(
pendingBundledPluginSync(completedLegacyCoreUpdate, "0.2.27"),
"refs/tags/v0.2.27",
"a core update completed by the legacy updater must synchronize its bundled plugins on startup"
);
assert.equal(
pendingBundledPluginSync({
...completedLegacyCoreUpdate,
bundled_plugins_synced_for_version: "0.2.27"
}, "0.2.27"),
null,
"completed bundled-plugin synchronization must be idempotent"
);
assert.equal(
pendingBundledPluginSync({ branch: "refs/tags/v0.2.27" }, "0.2.27"),
null,
"repository metadata alone must never trigger startup writes"
);
const corrected = buildStatus({
kind: "core",
@ -119,7 +148,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.2.26");
assert.equal(corrected.safe_target_version, "0.2.27");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);
@ -146,6 +175,11 @@ assert.match(
/async function applyCoreUpdate[\s\S]*include_plugin_code: true[\s\S]*target_plugin_ids: targetPluginIds[\s\S]*applyCoreFilesFromDirectory\(managed\.path\)[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\), pluginId, \{ preserveData: true \}\)[\s\S]*syncPluginRegistry\(\)/,
"stable core updates must transactionally carry bundled plugins and newly introduced Companion plugins"
);
assert.match(
repoUpdateSource,
/async function synchronizeBundledPluginsForCompletedCoreUpdate[\s\S]*ensureManagedRepo\(configuredRemote, sourceRef\)[\s\S]*include_plugin_code: true[\s\S]*applyPluginFiles\(path\.join\(managed\.path, "plugins", pluginId\), pluginId, \{ preserveData: true \}\)[\s\S]*bundled_plugins_synced_for_version: currentVersion[\s\S]*restoreSnapshot\(snapshotRecord\.id/,
"legacy core updates must complete bundled-plugin synchronization transactionally on startup"
);
const updateManagerSource = fs.readFileSync(path.join(root, "src", "services", "update-manager.js"), "utf8");
assert.match(updateManagerSource, /from_source_branch: snapshot\.metadata\?\.from_source_branch/);
assert.match(updateManagerSource, /target_commit: snapshot\.metadata\?\.target_commit/);
@ -157,9 +191,11 @@ const updaterSource = fs.readFileSync(path.join(root, "src", "services", "update
const mainSource = fs.readFileSync(path.join(root, "src", "main.js"), "utf8");
assert.match(updaterSource, /function registerRestartHandler/);
assert.match(updaterSource, /async function completePendingBranchDeployment/);
assert.match(updaterSource, /async function completePendingBundledPluginSync/);
assert.match(updaterSource, /state\.last_update_status === "complete"[\s\S]*state\.last_target_kind === "core"[\s\S]*state\.last_target_version === localVersion/);
assert.match(mainSource, /registerRestartHandler\(\(\) => shutdown\(10\)\)/);
assert.match(mainSource, /await completePendingBranchDeployment\(configuredRemote, configuredBranch\)/);
assert.match(mainSource, /await completePendingBundledPluginSync\(configuredRemote\)/);
assert.match(mainSource, /overlayConnectorManager\.stop\(\)[\s\S]*stopPlugins\(\)[\s\S]*process\.exit\(Number\(exitCode\) === 10 \? 10 : 0\)/);
const updatesView = fs.readFileSync(path.join(root, "src", "web", "views", "admin-updates.ejs"), "utf8");
assert.match(serverSource, /app\.post\("\/admin\/updates\/core\/deploy-branch"/);

View File

@ -11,7 +11,14 @@ const stopPlugins =
typeof pluginService.stopPlugins === "function"
? pluginService.stopPlugins
: async () => {};
const { checkForUpdates, completePendingBranchDeployment, pullUpdates, registerRestartHandler, requestRestart } = require("./services/updater");
const {
checkForUpdates,
completePendingBranchDeployment,
completePendingBundledPluginSync,
pullUpdates,
registerRestartHandler,
requestRestart
} = require("./services/updater");
const { createCommandRouter } = require("./services/command-router");
const { registerTopCommand } = require("./services/top");
const logger = require("./services/logger");
@ -37,6 +44,13 @@ async function main() {
requestRestart({ delayMs: 0 });
return;
}
const completedPluginSync = await completePendingBundledPluginSync(configuredRemote);
if (completedPluginSync) {
console.log(
`Completed bundled plugin synchronization for Lumi ${completedPluginSync.version} ` +
`(${completedPluginSync.plugin_ids.length} plugins).`
);
}
registerCorePlaceholders();
logger.hookConsole();
const runtimeLog = logger.createLogger("core:runtime", { category: "lifecycle" });

View File

@ -205,7 +205,11 @@ async function applyCoreUpdate({ source = "stable", remote = null, version = nul
last_error: null,
last_snapshot_id: record.id,
last_target_kind: "core",
last_target_version: target.safe_target_version
last_target_version: target.safe_target_version,
bundled_plugins_sync_status: "complete",
bundled_plugins_synced_for_version: target.safe_target_version,
bundled_plugins_synced_commit: managed.commit,
bundled_plugins_synced_at: new Date().toISOString()
});
emitProgress(publish, "update:restart_required", { target: "core" });
emitProgress(publish, "update:complete", { target: "core", snapshot_id: record.id });
@ -241,6 +245,107 @@ async function applyCoreUpdate({ source = "stable", remote = null, version = nul
});
}
async function synchronizeBundledPluginsForCompletedCoreUpdate({
remote = null,
sourceRef,
expectedVersion
} = {}) {
return withOperation("core:bundled-plugin-sync", async () => {
const currentVersion = verifyCoreFiles(repoRoot, expectedVersion);
const configuredRemote = remote || getSetting("git_remote", "origin");
let managed = null;
let marker = null;
let snapshot = null;
let snapshotRecord = null;
let targetPluginIds = [];
let stage = "opening the completed core release";
try {
managed = ensureManagedRepo(configuredRemote, sourceRef);
verifyCoreFiles(managed.path, currentVersion);
targetPluginIds = bundledPluginIds(managed.path);
for (const pluginId of targetPluginIds) verifyPluginFiles(pluginId, managed.path);
stage = "preparing a bundled-plugin rollback snapshot";
marker = createRecoveryMarker({
target_kind: "core",
target_id: "bundled_plugins",
from_version: currentVersion,
to_version: currentVersion,
source_branch: managed.branch,
update_method: "startup_bundled_plugin_sync",
rollback_safe: true,
major_crossing: false
});
updateRecoveryMarker({ status: "applying" });
snapshot = await createSnapshot({
type: "bot",
metadata: {
target_kind: "core",
target_id: "bundled_plugins",
from_version: currentVersion,
to_version: currentVersion,
source_branch: managed.branch,
target_commit: managed.commit,
update_method: "startup_bundled_plugin_sync",
rollback_safe: true,
recovery_marker_id: marker.id,
include_plugin_code: true,
target_plugin_ids: targetPluginIds
}
});
snapshotRecord = finalizeSnapshot(snapshot);
stage = "synchronizing bundled plugins";
for (const pluginId of targetPluginIds) {
applyPluginFiles(path.join(managed.path, "plugins", pluginId), pluginId, { preserveData: true });
}
updateRecoveryMarker({ status: "verifying" });
for (const pluginId of targetPluginIds) verifyPluginFiles(pluginId, repoRoot);
syncPluginRegistry();
markRecoveryMarkerComplete({ snapshot_id: snapshotRecord.id });
writeUpdateState({
bundled_plugins_sync_status: "complete",
bundled_plugins_sync_error: null,
bundled_plugins_synced_for_version: currentVersion,
bundled_plugins_synced_commit: managed.commit,
bundled_plugins_synced_at: new Date().toISOString()
});
return {
status: "complete",
version: currentVersion,
source_ref: managed.branch,
commit: managed.commit,
plugin_ids: targetPluginIds,
snapshot: snapshotRecord
};
} catch (error) {
const originalMessage = error?.message || String(error || "Unknown synchronization error.");
error.message = `Bundled plugin startup synchronization failed while ${stage}: ${originalMessage}`;
if (snapshotRecord) {
try {
restoreSnapshot(snapshotRecord.id, {
expectedType: "bot",
currentVersion,
allowUnsafeMajorRollback: true
});
syncPluginRegistry();
} catch (restoreError) {
error.message += ` Automatic restore also failed: ${restoreError.message}`;
}
} else if (snapshot) {
try { discardSnapshot(snapshot); } catch {}
}
if (marker) markRecoveryMarkerFailed(error);
writeUpdateState({
bundled_plugins_sync_status: "failed",
bundled_plugins_sync_error: error.message,
bundled_plugins_sync_failed_at: new Date().toISOString()
});
throw error;
}
});
}
async function deployCoreBranch({ branch, remote = null, publish } = {}) {
return withOperation("core", async () => {
const targetBranch = normalizeDeploymentBranch(branch);
@ -346,7 +451,11 @@ async function deployCoreBranch({ branch, remote = null, publish } = {}) {
last_snapshot_id: snapshotRecord.id,
last_target_kind: "core",
last_target_version: target.version,
last_deployment_method: "branch_deploy"
last_deployment_method: "branch_deploy",
bundled_plugins_sync_status: "complete",
bundled_plugins_synced_for_version: target.version,
bundled_plugins_synced_commit: managed.commit,
bundled_plugins_synced_at: new Date().toISOString()
});
emitProgress(publish, "update:restart_required", { target: "core", deployment });
emitProgress(publish, "update:complete", { target: "core", snapshot_id: snapshotRecord.id, deployment });
@ -564,6 +673,7 @@ function disablePluginForRecovery(pluginId, publish) {
module.exports = {
applyCoreUpdate,
synchronizeBundledPluginsForCompletedCoreUpdate,
deployCoreBranch,
applyPluginUpdateFromRepo,
targetForRequestedVersion,

View File

@ -1,5 +1,9 @@
const { getUpdateStatus } = require("./update-index");
const { applyCoreUpdate, deployCoreBranch } = require("./repo-update");
const {
applyCoreUpdate,
deployCoreBranch,
synchronizeBundledPluginsForCompletedCoreUpdate
} = require("./repo-update");
const {
normalizeRepositoryTarget,
readUpdateState,
@ -57,6 +61,32 @@ async function completePendingBranchDeployment(remote, configuredBranch) {
return deployCoreBranch({ branch: targetBranch, remote });
}
function pendingBundledPluginSync(state, localVersion) {
const confirmedCoreUpdate =
state.last_update_status === "complete" &&
state.last_target_kind === "core" &&
state.last_target_version === localVersion &&
Boolean(state.last_snapshot_id);
if (
!confirmedCoreUpdate ||
state.bundled_plugins_synced_for_version === localVersion
) return null;
const sourceRef = String(state.branch || "").trim();
return sourceRef || null;
}
async function completePendingBundledPluginSync(remote) {
const state = readUpdateState();
const localVersion = require("../../package.json").version;
const sourceRef = pendingBundledPluginSync(state, localVersion);
if (!sourceRef) return null;
return synchronizeBundledPluginsForCompletedCoreUpdate({
remote,
sourceRef,
expectedVersion: localVersion
});
}
function registerRestartHandler(handler) {
restartHandler = typeof handler === "function" ? handler : null;
return () => {
@ -81,9 +111,11 @@ function requestRestart(options = {}) {
module.exports = {
checkForUpdates,
completePendingBranchDeployment,
completePendingBundledPluginSync,
pullUpdates,
normalizeRepositoryTarget,
pendingDeploymentBranch,
pendingBundledPluginSync,
resolveUpdateBranch,
registerRestartHandler,
requestRestart

View File

@ -1,6 +1,6 @@
{
"name": "Lumi Core",
"version": "0.2.26",
"version": "0.2.27",
"channel": "stable",
"released_at": "2026-07-24",
"compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [
"1.2.0"
],
"migration_notes": "Adds the stable Lumi Companion, server-hosted transcription, Song Overlay, shared paired-device authentication, and synchronized core-plus-bundled-plugin updates. Existing settings, databases, pairing records, tokens, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.",
"migration_notes": "Completes bundled-plugin synchronization automatically after updates performed by the legacy 0.2.25 core-only updater. The synchronization is snapshot-backed, exact-release verified, idempotent, and preserves settings, databases, pairing records, tokens, overlays, uploads, models, secrets, plugin data, and local-only plugins.",
"rollback_safe": true,
"requirements": [
"Node.js 18 or newer"
@ -325,6 +325,18 @@
],
"rollback_safe": true,
"migration_notes": "Adds the shared DesignMotionHQ UX foundation, repaired overlay and resource-library workflows, and verified snapshot-backed switching between main and exact experimental branches. Existing content, settings, tokens, overlays, databases, uploads, models, feedback, secrets, plugin data, and local-only plugins are preserved."
},
{
"version": "0.2.26",
"channel": "stable",
"released_at": "2026-07-24",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Adds the stable Lumi Companion, server-hosted transcription, Song Overlay, shared paired-device authentication, and synchronized core-plus-bundled-plugin updates. Existing settings, databases, pairing records, tokens, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
}
]
}