From b724e01a7619664e8a844fd62c5e1304807a590d Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Tue, 5 May 2026 17:18:34 -0500 Subject: [PATCH 1/4] feat(bundles): add upgrade() and installSource to BundleLifecycleManager Best-effort hot-swap: stops the running source, re-installs from mpak, and starts the new version. Clears stale automations before re-syncing. Refreshes trustScore and unconditionally unregisters placements before re-registering. Adds installSource ("registry" | "local" | "remote") to BundleInstance and AppInfo for explicit install-channel tracking. Closes #35 Co-Authored-By: Claude Opus 4.6 --- src/adapters/workspace-log-sink.ts | 1 + src/bundles/lifecycle.ts | 116 ++++++++++++++++++++++++++++- src/bundles/types.ts | 3 + src/engine/types.ts | 1 + src/runtime/runtime.ts | 1 + 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/adapters/workspace-log-sink.ts b/src/adapters/workspace-log-sink.ts index 81e0dab3..f6215147 100644 --- a/src/adapters/workspace-log-sink.ts +++ b/src/adapters/workspace-log-sink.ts @@ -6,6 +6,7 @@ import type { EngineEvent, EngineEventType, EventSink } from "../engine/types.ts const WORKSPACE_EVENTS = new Set([ "bundle.installed", "bundle.uninstalled", + "bundle.upgraded", "bundle.crashed", "bundle.recovered", "bundle.dead", diff --git a/src/bundles/lifecycle.ts b/src/bundles/lifecycle.ts index b8c30bc4..00c7bde3 100644 --- a/src/bundles/lifecycle.ts +++ b/src/bundles/lifecycle.ts @@ -22,6 +22,11 @@ import type { RemoteTransportConfig, } from "./types.ts"; +/** Resolve work dir at call time — env var may be overridden in tests. */ +function resolveWorkDir(): string { + return process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain"); +} + // --------------------------------------------------------------------------- // BundleLifecycleManager — owns the state of all installed bundles and // provides the formal install / uninstall / start / stop / restart flows @@ -120,7 +125,7 @@ export class BundleLifecycleManager { // Workspace-scoped data dir keeps two workspaces installing the same // bundle from stomping on each other's entity data. Matches the // seedInstance layout used at platform boot. - const nbWorkDir = process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain"); + const nbWorkDir = resolveWorkDir(); const bundleDataDir = join(nbWorkDir, "workspaces", wsId, "data", deriveBundleDataDir(name)); const { sourceName, manifest } = await startBundleSource( @@ -139,6 +144,7 @@ export class BundleLifecycleManager { const isUpjack = manifest._meta?.["ai.nimblebrain/upjack"] != null; const instance = createInstance(sourceName, name, manifest, isUpjack, wsId); instance.configKey = name; + instance.installSource = "registry"; this.transition(instance, "running"); instance.trustScore = await fetchTrustScore(name, this.mpakHome); @@ -199,6 +205,7 @@ export class BundleLifecycleManager { // Use manifest.name (scoped name) as bundleName, not the filesystem path. const instance = createInstance(sourceName, manifest.name, manifest, isUpjack, wsId); instance.configKey = bundlePath; // config entry uses the filesystem path + instance.installSource = "local"; this.transition(instance, "running"); instance.ui = extractUiMeta(manifest); @@ -248,7 +255,7 @@ export class BundleLifecycleManager { // via `installRemote` from any workspace would share OAuth tokens across // workspaces under the default id — silent cross-tenant credential // leakage. - const nbWorkDir = process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain"); + const nbWorkDir = resolveWorkDir(); const { sourceName, meta } = await startBundleSource( { url, serverName, transport: transportConfig, ui: ui ?? null }, registry, @@ -269,6 +276,7 @@ export class BundleLifecycleManager { protected: false, type: "plain", wsId, + installSource: "remote", }; this.transition(instance, "running"); @@ -367,7 +375,7 @@ export class BundleLifecycleManager { // Credentials are config, not data — they should not persist across // uninstalls. Data directories are preserved (step 6). if (instance) { - const workDir = process.env.NB_WORK_DIR ?? join(homedir(), ".nimblebrain"); + const workDir = resolveWorkDir(); try { await clearAllWorkspaceCredentials(instance.wsId, instance.bundleName, workDir); } catch (err) { @@ -385,6 +393,104 @@ export class BundleLifecycleManager { }); } + // ---- Upgrade ----------------------------------------------------------- + + /** + * Upgrade a named bundle to the latest version from the mpak registry. + * + * Best-effort hot-swap: tears down the old process, then starts the new + * version. Brief unavailability window during the swap (sub-second, + * same pattern as `configureBundle`). If the new version fails to start + * the bundle is left unregistered until a reinstall or restart. + * + * No `protected` guard — protected bundles can be upgraded (but not + * uninstalled) so they aren't frozen out of security updates. + * + * Preserves: workspace-scoped data dir, credentials, config entry. + * Emits: bundle.upgraded event on success. + */ + async upgrade( + name: string, + wsId: string, + registry: ToolRegistry, + ): Promise<{ from: string; to: string; serverName: string }> { + const serverName = deriveServerName(name); + const instance = this.instances.get(`${serverName}|${wsId}`); + if (!instance) { + throw new Error(`No bundle instance found for "${name}" in workspace "${wsId}"`); + } + + const mpak = getMpak(this.mpakHome); + const fromVersion = instance.version; + + // Check if a newer version exists + const latest = await mpak.bundleCache.checkForUpdate(name, { force: true }); + if (!latest) { + return { from: fromVersion, to: fromVersion, serverName }; + } + + // Fetch the new artifact into cache + await mpak.bundleCache.loadBundle(name, { force: true }); + + // Resolve workspace-scoped paths (same as installNamed) + const nbWorkDir = resolveWorkDir(); + const bundleDataDir = join(nbWorkDir, "workspaces", wsId, "data", deriveBundleDataDir(name)); + + // Remove old source, then spawn new process. The registry throws on + // duplicate names so we can't hold both simultaneously. The window is + // sub-second (process spawn time) — same pattern as configureBundle. + if (registry.hasSource(serverName)) { + await registry.removeSource(serverName); + } + + const { sourceName: newSourceName, manifest } = await startBundleSource( + { name }, + registry, + this.eventSink, + this.configPath ? dirname(this.configPath) : undefined, + { dataDir: bundleDataDir, wsId, workDir: nbWorkDir }, + ); + if (!manifest) { + throw new Error(`No manifest found for ${name} after upgrade fetch`); + } + + // Update instance metadata in place + const isUpjack = manifest._meta?.["ai.nimblebrain/upjack"] != null; + instance.version = manifest.version; + instance.description = manifest.description; + instance.type = isUpjack ? "upjack" : "plain"; + instance.ui = extractUiMeta(manifest); + instance.briefing = extractBriefing(manifest); + instance.trustScore = await fetchTrustScore(name, this.mpakHome); + this.transition(instance, "running"); + + // Always unregister stale placements, then re-register if new manifest + // declares any. Without unconditional unregister, a version that drops + // all placements would leave stale nav entries. + this.placementRegistry?.unregister(serverName, wsId); + if (instance.ui?.placements) { + this.placementRegistry?.register(newSourceName, instance.ui.placements, wsId); + } + + // Clean stale automations then sync from new manifest. Without the + // remove, schedules dropped between versions keep running with stale prompts. + await this.removeBundleAutomations(name, registry); + await this.syncBundleAutomations(manifest, name, registry); + + this.eventSink.emit({ + type: "bundle.upgraded", + data: { + serverName, + bundleName: name, + fromVersion, + toVersion: manifest.version, + wsId, + }, + }); + + return { from: fromVersion, to: manifest.version, serverName }; + } + // ---- Start / Stop / Restart ------------------------------------------- /** @@ -640,12 +746,16 @@ export class BundleLifecycleManager { ? join(dataDir, manifestMeta.upjackNamespace, "data") : undefined; + const installSource: BundleInstance["installSource"] = + "name" in ref ? "registry" : "url" in ref ? "remote" : "local"; + const instance: BundleInstance = { serverName, // Prefer the scoped manifest name over the config label (filesystem path) bundleName: manifestMeta?.manifestName ?? bundleName, // Config key for reliable uninstall — the original value from nimblebrain.json configKey: bundleName, + installSource, version: manifestMeta?.version ?? "unknown", description: manifestMeta?.description, state: "running", diff --git a/src/bundles/types.ts b/src/bundles/types.ts index 303ad438..ebe015eb 100644 --- a/src/bundles/types.ts +++ b/src/bundles/types.ts @@ -191,6 +191,8 @@ export interface BundleInstance { bundleName: string; /** The config key used to find this bundle in nimblebrain.json (name, path, or url value). */ configKey?: string; + /** How this bundle was installed. Used to distinguish registry bundles from local dev copies. */ + installSource?: "registry" | "local" | "remote"; /** Version from manifest. */ version: string; /** Human-readable description from the manifest. */ @@ -267,4 +269,5 @@ export interface AppInfo { toolCount: number; trustScore: number; ui: BundleUiMeta | null; + installSource?: "registry" | "local" | "remote"; } diff --git a/src/engine/types.ts b/src/engine/types.ts index 7cad475c..d07d0d8d 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -58,6 +58,7 @@ export type EngineEventType = | "context.assembled" | "bundle.installed" | "bundle.uninstalled" + | "bundle.upgraded" | "bundle.crashed" | "bundle.recovered" | "bundle.dead" diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 29fe204c..80024706 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1778,6 +1778,7 @@ export class Runtime { toolCount, trustScore: instance.trustScore ?? 0, ui: instance.ui, + installSource: instance.installSource, }); } return apps; From 1a6f41ba1ea19fe738836da92856013da452bf1d Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Tue, 5 May 2026 17:18:40 -0500 Subject: [PATCH 2/4] feat(tools): add upgrade action and check_updates tool manage_app "upgrade" delegates to lifecycle.upgrade(). check_updates filters on installSource === "registry" instead of bundleName heuristic. Co-Authored-By: Claude Opus 4.6 --- src/tools/system-tools.ts | 124 +++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/src/tools/system-tools.ts b/src/tools/system-tools.ts index ceb46c71..732308c7 100644 --- a/src/tools/system-tools.ts +++ b/src/tools/system-tools.ts @@ -163,14 +163,15 @@ export async function createSystemTools( { name: "manage_app", description: - "Install, uninstall, or configure an app. 'configure' prompts for API keys/credentials securely via the terminal. Requires user approval.", + "Install, uninstall, upgrade, or configure an app. 'upgrade' pulls the latest version from the registry and hot-swaps the running bundle. 'configure' prompts for API keys/credentials securely via the terminal. Requires user approval.", inputSchema: { type: "object", properties: { action: { type: "string", - enum: ["install", "uninstall", "configure"], - description: "Action: install, uninstall, or configure (set credentials)", + enum: ["install", "uninstall", "configure", "upgrade"], + description: + "Action: install, uninstall, configure (set credentials), or upgrade (pull latest version)", }, name: { type: "string", @@ -224,11 +225,15 @@ export async function createSystemTools( mpakHome, ); } + if (action === "upgrade") { + return await upgradeBundle(name, wsId, lifecycle, getRegistry()); + } return { content: textContent(`Unknown action: ${action}`), isError: true }; }, }, createReadResourceTool(getRegistry), createStatusTool(getRegistry, getSkills, lifecycle, runtime), + createCheckUpdatesTool(lifecycle, mpakHome, manageBundleCtx), ]; if (delegateCtx) { @@ -899,6 +904,119 @@ async function uninstallBundleFromWorkspaceViaCtx( } } +async function upgradeBundle( + name: string, + wsId: string, + lifecycle: BundleLifecycleManager, + registry: ToolRegistry, +): Promise { + try { + const { from, to, serverName } = await lifecycle.upgrade(name, wsId, registry); + if (from === to) { + return { + content: textContent(`${name} is already at the latest version (${from}).`), + isError: false, + }; + } + const tools = await registry.availableTools(); + const count = tools.filter((t) => t.name.startsWith(`${serverName}__`)).length; + return { + content: textContent(`Upgraded ${name}: ${from} → ${to}. ${count} tools available.`), + isError: false, + }; + } catch (err) { + return { + content: textContent( + `Failed to upgrade ${name}: ${err instanceof Error ? err.message : String(err)}`, + ), + isError: true, + }; + } +} + +function createCheckUpdatesTool( + lifecycle?: BundleLifecycleManager, + mpakHome?: string, + manageBundleCtx?: ManageBundleContext, +): InProcessTool { + return { + name: "check_updates", + description: + "Check installed bundles for available updates from the mpak registry. Returns a list of bundles that have newer versions available.", + inputSchema: { + type: "object", + properties: {}, + }, + handler: async (): Promise => { + if (!lifecycle || !mpakHome) { + return { + content: textContent("Update checking requires lifecycle and mpak context."), + isError: true, + }; + } + const wsId = manageBundleCtx?.getWorkspaceId(); + if (!wsId) { + return { + content: textContent("Workspace context required for update checking."), + isError: true, + }; + } + + const instances = lifecycle.getInstances().filter((i) => i.wsId === wsId); + // Only check registry-installed bundles (not local dev copies or remote URLs) + const named = instances.filter((i) => i.installSource === "registry"); + + if (named.length === 0) { + return { + content: textContent("All bundles are up to date (no registry bundles installed)."), + isError: false, + }; + } + + const mpak = getMpak(mpakHome); + const updates: Array<{ name: string; current: string; latest: string }> = []; + + await Promise.all( + named.map(async (instance) => { + try { + const latest = await mpak.bundleCache.checkForUpdate(instance.bundleName, { + force: true, + }); + if (latest) { + updates.push({ + name: instance.bundleName, + current: instance.version, + latest, + }); + } + } catch { + // Skip bundles that fail to check (may have been removed from registry) + } + }), + ); + + if (updates.length === 0) { + return { + content: textContent("All bundles are up to date."), + isError: false, + }; + } + + const lines = [`${updates.length} update(s) available:\n`]; + for (const u of updates.sort((a, b) => a.name.localeCompare(b.name))) { + lines.push(`- **${u.name}**: ${u.current} → ${u.latest}`); + } + lines.push(`\nRun \`nb__manage_app action=upgrade name=\` to upgrade.`); + + return { + content: textContent(lines.join("\n")), + isError: false, + structuredContent: { updates }, + }; + }, + }; +} + function groupToolsBySource(all: Array<{ name: string; description: string }>): ToolResult { const groups = new Map(); for (const tool of all) { From 5b07437c9a2881bb9ae59edab1fba0fcdbbb7dc5 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Tue, 5 May 2026 17:18:47 -0500 Subject: [PATCH 3/4] feat(web): add bundle update UI to About page Check for updates button, per-row upgrade with error handling. Update column only shown for registry-sourced bundles. Filters on installSource rather than bundleName prefix. Co-Authored-By: Claude Opus 4.6 --- web/src/pages/settings/AboutTab.tsx | 133 +++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 14 deletions(-) diff --git a/web/src/pages/settings/AboutTab.tsx b/web/src/pages/settings/AboutTab.tsx index 37eeb892..9cec9b2d 100644 --- a/web/src/pages/settings/AboutTab.tsx +++ b/web/src/pages/settings/AboutTab.tsx @@ -11,7 +11,8 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; -import { EmptyState, InlineError, Section, SettingsDashboardPage } from "./components"; +import { roleAtLeast, useScopedRole } from "../../hooks/useScopedRole"; +import { InlineError, Section, SettingsDashboardPage } from "./components"; interface AppInfo { name: string; @@ -20,10 +21,16 @@ interface AppInfo { status: string; type: string; toolCount: number; + installSource?: "registry" | "local" | "remote"; +} + +interface UpdateInfo { + name: string; + current: string; + latest: string; } function mpakUrl(bundleName: string): string | null { - // Scoped names like @nimblebraininc/echo → mpak.dev/packages/@nimblebraininc/echo if (bundleName.startsWith("@")) return `https://mpak.dev/packages/${bundleName}`; return null; } @@ -44,24 +51,44 @@ function statusColor(status: string): "default" | "secondary" | "destructive" | export function AboutTab() { const { version, buildSha } = getPlatformVersion(); + const role = useScopedRole(); + const canUpgrade = roleAtLeast(role, "org_admin"); + const [apps, setApps] = useState([]); const [loading, setLoading] = useState(true); const [bundlesError, setBundlesError] = useState(null); + const [updates, setUpdates] = useState>(new Map()); + const [updatesChecked, setUpdatesChecked] = useState(false); + const [upgrading, setUpgrading] = useState>(new Set()); + const [upgradeErrors, setUpgradeErrors] = useState>(new Map()); const fetchApps = useCallback(async () => { try { setBundlesError(null); - const result = await callTool("nb", "list_apps", {}); - const data = parseToolResult<{ apps?: AppInfo[] }>(result); + const [appsResult, updatesResult] = await Promise.all([ + callTool("nb", "list_apps", {}), + callTool("nb", "check_updates", {}).catch(() => null), + ]); + const data = parseToolResult<{ apps?: AppInfo[] }>(appsResult); if (Array.isArray(data.apps)) { setApps(data.apps); } + if (updatesResult) { + const map = new Map(); + try { + const updateData = parseToolResult<{ updates?: UpdateInfo[] }>(updatesResult); + if (Array.isArray(updateData.updates)) { + for (const u of updateData.updates) { + map.set(u.name, u.latest); + } + } + } catch { + // No structured update data — all bundles up to date + } + setUpdates(map); + setUpdatesChecked(true); + } } catch (err) { - // Surface the failure rather than silently degrading to "no bundles - // installed" — the empty state would otherwise read as authoritative - // ("there are no bundles") when really the call failed and we don't - // know. The platform-version section is independent (read from - // bootstrap), so the page still renders useful content above. setBundlesError(err instanceof Error ? err.message : "Failed to load installed bundles."); } finally { setLoading(false); @@ -72,6 +99,53 @@ export function AboutTab() { fetchApps(); }, [fetchApps]); + const handleUpgrade = useCallback( + async (bundleName: string) => { + setUpgrading((prev) => new Set(prev).add(bundleName)); + setUpgradeErrors((prev) => { + const next = new Map(prev); + next.delete(bundleName); + return next; + }); + try { + const result = await callTool("nb", "manage_app", { + action: "upgrade", + name: bundleName, + }); + if (result.isError) { + const msg = + result.content + ?.filter((c) => c.type === "text") + .map((c) => c.text ?? "") + .join("") || "Upgrade failed"; + setUpgradeErrors((prev) => { + const next = new Map(prev); + next.set(bundleName, msg); + return next; + }); + return; + } + await fetchApps(); + } catch (err) { + setUpgradeErrors((prev) => { + const next = new Map(prev); + next.set(bundleName, err instanceof Error ? err.message : "Upgrade failed"); + return next; + }); + } finally { + setUpgrading((prev) => { + const next = new Set(prev); + next.delete(bundleName); + return next; + }); + } + }, + [fetchApps], + ); + + const hasRegistryBundles = apps.some((a) => a.installSource === "registry"); + const showUpdateColumn = updatesChecked && hasRegistryBundles; + return ( } /> - ) : apps.length === 0 ? ( - - ) : ( + ) : apps.length === 0 ? null : ( @@ -115,11 +187,15 @@ export function AboutTab() { Version Status Tools + {showUpdateColumn && Update} {apps.map((app) => { const href = mpakUrl(app.bundleName); + const latestVersion = updates.get(app.bundleName); + const isUpgrading = upgrading.has(app.bundleName); + const upgradeError = upgradeErrors.get(app.bundleName); return ( @@ -138,11 +214,40 @@ export function AboutTab() { {app.version || "—"} - - {app.status} + + {isUpgrading ? "updating" : app.status} {app.toolCount} + {showUpdateColumn && ( + + {app.installSource !== "registry" ? null : latestVersion ? ( +
+ + {app.version} → {latestVersion} + + {canUpgrade && ( + + )} + {upgradeError && ( + {upgradeError} + )} +
+ ) : ( + Up to date + )} +
+ )}
); })} From fd10a356d8fbcbfd70a5cd4959f4e787544d2579 Mon Sep 17 00:00:00 2001 From: Mason <31372737+Ovaculos@users.noreply.github.com> Date: Tue, 5 May 2026 17:18:52 -0500 Subject: [PATCH 4/4] test: add unit tests for bundle upgrade flow 13 tests covering upgrade action guards, schema validation, check_updates filtering by installSource, upgrade no-op when already at latest, and unknown instance error. Co-Authored-By: Claude Opus 4.6 --- test/unit/bundle-upgrade.test.ts | 369 +++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 test/unit/bundle-upgrade.test.ts diff --git a/test/unit/bundle-upgrade.test.ts b/test/unit/bundle-upgrade.test.ts new file mode 100644 index 00000000..fd9dd0cb --- /dev/null +++ b/test/unit/bundle-upgrade.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { NoopEventSink } from "../../src/adapters/noop-events.ts"; +import { extractText } from "../../src/engine/content-helpers.ts"; +import { textContent } from "../../src/engine/content-helpers.ts"; +import { BundleLifecycleManager } from "../../src/bundles/lifecycle.ts"; +import { createSystemTools } from "../../src/tools/system-tools.ts"; +import type { ManageBundleContext } from "../../src/tools/system-tools.ts"; +import { ToolRegistry } from "../../src/tools/registry.ts"; +import { makeInProcessSource } from "../helpers/in-process-source.ts"; +import { WorkspaceStore } from "../../src/workspace/workspace-store.ts"; + +const noopSink = new NoopEventSink(); +const wsId = "ws_upgrade_test"; +const bundleName = "@testscope/upgradeable"; + +/** + * Write a fake manifest into the mpak cache so getBundleManifest() works. + */ +function writeFakeBundle( + mpakHome: string, + name: string, + version: string, +): void { + const safeName = name.replace("@", "").replace("/", "-"); + const cacheDir = join(mpakHome, "cache", safeName); + mkdirSync(cacheDir, { recursive: true }); + const manifest = { + manifest_version: "0.4", + name, + version, + description: "Test bundle", + author: { name: "Test" }, + server: { + type: "node", + entry_point: "server.js", + mcp_config: { command: "node", args: ["${__dirname}/server.js"] }, + }, + }; + writeFileSync(join(cacheDir, "manifest.json"), JSON.stringify(manifest)); + writeFileSync( + join(cacheDir, ".mpak-meta.json"), + JSON.stringify({ version, pulledAt: new Date().toISOString(), platform: { os: "darwin", arch: "arm64" } }), + ); +} + +async function makeRegistry() { + const registry = new ToolRegistry(); + const source = await makeInProcessSource("test", [ + { + name: "ping", + description: "Ping", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("pong"), isError: false }), + }, + ]); + registry.addSource(source); + return registry; +} + +async function buildTools(opts: { + mpakHome: string; + workDir: string; + lifecycle: BundleLifecycleManager; +}) { + const registry = await makeRegistry(); + const store = new WorkspaceStore(opts.workDir); + const ctx: ManageBundleContext = { + getWorkspaceId: () => wsId, + workspaceStore: store, + workDir: opts.workDir, + configDir: undefined, + eventSink: noopSink, + }; + const tools = await createSystemTools( + () => registry, + undefined, + undefined, + opts.lifecycle, + undefined, + undefined, + undefined, + undefined, + noopSink, + undefined, + undefined, + opts.mpakHome, + undefined, + undefined, + undefined, + undefined, + ctx, + ); + return { tools, registry }; +} + +// --------------------------------------------------------------------------- +// manage_app action=upgrade +// --------------------------------------------------------------------------- + +describe("manage_app action=upgrade", () => { + let workDir: string; + let mpakHome: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "nb-upgrade-test-")); + mpakHome = mkdtempSync(join(tmpdir(), "nb-upgrade-mpak-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + rmSync(mpakHome, { recursive: true, force: true }); + }); + + it("upgrade requires lifecycle context", async () => { + const registry = await makeRegistry(); + const tools = await createSystemTools(() => registry); + const result = await tools.execute("manage_app", { + action: "upgrade", + name: bundleName, + }); + expect(result.isError).toBe(true); + expect(extractText(result.content)).toContain("lifecycle context"); + }); + + it("upgrade requires workspace context (via manageBundleCtx)", async () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + const registry = await makeRegistry(); + // Pass lifecycle but no manageBundleCtx — should fail at the + // shared guard that checks both before dispatching any action. + const tools = await createSystemTools( + () => registry, + undefined, + undefined, + lifecycle, + ); + const result = await tools.execute("manage_app", { + action: "upgrade", + name: bundleName, + }); + expect(result.isError).toBe(true); + expect(extractText(result.content)).toContain("lifecycle context"); + }); + + it("upgrade action is listed in tool schema", async () => { + const registry = await makeRegistry(); + const tools = await createSystemTools(() => registry); + const allTools = await tools.tools(); + const manageApp = allTools.find((t) => t.name === "nb__manage_app"); + expect(manageApp).toBeDefined(); + const schema = manageApp!.inputSchema as { properties: { action: { enum: string[] } } }; + expect(schema.properties.action.enum).toContain("upgrade"); + }); +}); + +// --------------------------------------------------------------------------- +// check_updates tool +// --------------------------------------------------------------------------- + +describe("check_updates tool", () => { + let workDir: string; + let mpakHome: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "nb-checkup-test-")); + mpakHome = mkdtempSync(join(tmpdir(), "nb-checkup-mpak-")); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + rmSync(mpakHome, { recursive: true, force: true }); + }); + + it("check_updates tool is registered", async () => { + const registry = await makeRegistry(); + const tools = await createSystemTools(() => registry); + const allTools = await tools.tools(); + const names = allTools.map((t) => t.name); + expect(names).toContain("nb__check_updates"); + }); + + it("check_updates returns empty when no bundles installed", async () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + const { tools } = await buildTools({ mpakHome, workDir, lifecycle }); + const result = await tools.execute("check_updates", {}); + expect(result.isError).toBe(false); + const text = extractText(result.content); + expect(text).toContain("up to date"); + }); + + it("check_updates reports up-to-date when registry has no newer version", async () => { + writeFakeBundle(mpakHome, bundleName, "0.1.0"); + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + lifecycle.seedInstance( + "upgradeable", + bundleName, + { name: bundleName }, + { version: "0.1.0", ui: null, briefing: null, type: "plain", httpProxy: null }, + wsId, + ); + const { tools } = await buildTools({ mpakHome, workDir, lifecycle }); + const result = await tools.execute("check_updates", {}); + expect(result.isError).toBe(false); + // No real registry to query → checkForUpdate returns null → "up to date" + expect(extractText(result.content)).toContain("up to date"); + expect(result.structuredContent).toBeUndefined(); + }); + + it("check_updates only checks registry bundles, skips path-based", async () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + lifecycle.seedInstance( + "local-bundle", + "/path/to/bundle", + { path: "/path/to/bundle" }, + { version: "1.0.0", ui: null, briefing: null, type: "plain", httpProxy: null }, + wsId, + ); + const { tools } = await buildTools({ mpakHome, workDir, lifecycle }); + const result = await tools.execute("check_updates", {}); + expect(result.isError).toBe(false); + expect(extractText(result.content)).toContain("up to date"); + }); + + it("check_updates skips local bundles even when manifest has scoped name", async () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + // Local bundle whose manifest has a scoped name — previously this would + // be offered for update because bundleName.startsWith("@") matched. + lifecycle.seedInstance( + "foo", + "/dev/my-bundles/foo", + { path: "/dev/my-bundles/foo" }, + { + manifestName: "@myorg/foo", + version: "0.1.0", + ui: null, + briefing: null, + type: "plain", + httpProxy: null, + }, + wsId, + ); + const instance = lifecycle.getInstance("foo", wsId); + expect(instance?.bundleName).toBe("@myorg/foo"); + expect(instance?.installSource).toBe("local"); + + const { tools } = await buildTools({ mpakHome, workDir, lifecycle }); + const result = await tools.execute("check_updates", {}); + expect(result.isError).toBe(false); + expect(extractText(result.content)).toContain("up to date"); + }); +}); + +// --------------------------------------------------------------------------- +// installSource field +// --------------------------------------------------------------------------- + +describe("installSource on BundleInstance", () => { + let mpakHome: string; + + beforeEach(() => { + mpakHome = mkdtempSync(join(tmpdir(), "nb-source-test-")); + }); + + afterEach(() => { + rmSync(mpakHome, { recursive: true, force: true }); + }); + + it("seedInstance sets installSource=registry for named bundles", () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + lifecycle.seedInstance( + "echo", + "@nimblebraininc/echo", + { name: "@nimblebraininc/echo" }, + { version: "1.0.0", ui: null, briefing: null, type: "plain", httpProxy: null }, + wsId, + ); + const instance = lifecycle.getInstance("echo", wsId); + expect(instance?.installSource).toBe("registry"); + }); + + it("seedInstance sets installSource=local for path bundles", () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + lifecycle.seedInstance( + "local-dev", + "/home/dev/bundles/test", + { path: "/home/dev/bundles/test" }, + { version: "0.1.0", ui: null, briefing: null, type: "plain", httpProxy: null }, + wsId, + ); + const instance = lifecycle.getInstance("local-dev", wsId); + expect(instance?.installSource).toBe("local"); + }); + + it("seedInstance sets installSource=remote for url bundles", () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + lifecycle.seedInstance( + "remote-svc", + "https://api.example.com/mcp", + { url: "https://api.example.com/mcp" }, + { version: "remote", ui: null, briefing: null, type: "plain", httpProxy: null }, + wsId, + ); + const instance = lifecycle.getInstance("remote-svc", wsId); + expect(instance?.installSource).toBe("remote"); + }); +}); + +// --------------------------------------------------------------------------- +// upgrade event emission +// --------------------------------------------------------------------------- + +describe("upgrade event emission", () => { + let mpakHome: string; + + beforeEach(() => { + mpakHome = mkdtempSync(join(tmpdir(), "nb-event-test-")); + }); + + afterEach(() => { + rmSync(mpakHome, { recursive: true, force: true }); + }); + + it("upgrade returns early when already at latest version", async () => { + const events: import("../../src/engine/types.ts").EngineEvent[] = []; + const sink = { emit: (e: import("../../src/engine/types.ts").EngineEvent) => events.push(e) }; + const lifecycle = new BundleLifecycleManager(sink, undefined, false, mpakHome); + lifecycle.seedInstance( + "upgradeable", + bundleName, + { name: bundleName }, + { version: "0.1.0", ui: null, briefing: null, type: "plain", httpProxy: null }, + wsId, + ); + + const registry = await makeRegistry(); + const fakeSource = await makeInProcessSource("upgradeable", [ + { + name: "hello", + description: "Hello", + inputSchema: { type: "object", properties: {} }, + handler: async () => ({ content: textContent("hi"), isError: false }), + }, + ]); + registry.addSource(fakeSource); + + // No newer version in mpak cache — upgrade should be a no-op + const result = await lifecycle.upgrade(bundleName, wsId, registry); + expect(result.from).toBe("0.1.0"); + expect(result.to).toBe("0.1.0"); + expect(result.serverName).toBe("upgradeable"); + + // Source untouched, no events emitted + expect(registry.hasSource("upgradeable")).toBe(true); + const upgradeEvents = events.filter((e) => e.type === "bundle.upgraded"); + expect(upgradeEvents).toHaveLength(0); + }); + + it("upgrade throws for unknown bundle instance", async () => { + const lifecycle = new BundleLifecycleManager(noopSink, undefined, false, mpakHome); + const registry = await makeRegistry(); + + await expect( + lifecycle.upgrade("@nonexistent/bundle", wsId, registry), + ).rejects.toThrow("No bundle instance found"); + }); +}); +