diff --git a/CHANGELOG.md b/CHANGELOG.md index 36b0add..c4579b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/branch-deployments.md b/docs/branch-deployments.md index 1c3583b..94e27b0 100644 --- a/docs/branch-deployments.md +++ b/docs/branch-deployments.md @@ -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 diff --git a/docs/updates.md b/docs/updates.md index 210fd0f..07817eb 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -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 diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index 9631ec0..a770275 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.26 +Version: 0.2.27 ## Routes - POST /api/diagnostics/v1/run - GET /api/events diff --git a/package-lock.json b/package-lock.json index 2035961..7404c23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2ce42ea..7ec8c16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.2.26", + "version": "0.2.27", "private": true, "type": "commonjs", "scripts": { diff --git a/release-index.json b/release-index.json index e5337f5..e8aeda4 100644 --- a/release-index.json +++ b/release-index.json @@ -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", diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index de105f0..82b4399 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -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."); diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 9bc69a7..2179cc1 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -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"/); diff --git a/src/main.js b/src/main.js index 3fa4022..45fd046 100644 --- a/src/main.js +++ b/src/main.js @@ -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" }); diff --git a/src/services/repo-update.js b/src/services/repo-update.js index ee9e097..61b4c34 100644 --- a/src/services/repo-update.js +++ b/src/services/repo-update.js @@ -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, diff --git a/src/services/updater.js b/src/services/updater.js index e153eb1..c132989 100644 --- a/src/services/updater.js +++ b/src/services/updater.js @@ -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 diff --git a/update-manifest.json b/update-manifest.json index 266cba9..d9b0d23 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -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." } ] }