Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/adapters/workspace-log-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { EngineEvent, EngineEventType, EventSink } from "../engine/types.ts
const WORKSPACE_EVENTS = new Set<EngineEventType>([
"bundle.installed",
"bundle.uninstalled",
"bundle.upgraded",
"bundle.crashed",
"bundle.recovered",
"bundle.dead",
Expand Down
116 changes: 113 additions & 3 deletions src/bundles/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -269,6 +276,7 @@ export class BundleLifecycleManager {
protected: false,
type: "plain",
wsId,
installSource: "remote",
};
this.transition(instance, "running");

Expand Down Expand Up @@ -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) {
Expand All @@ -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}"`);
}

Comment on lines +417 to +422
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No protected check on upgrade.

uninstall (L336) refuses on instance.protected. Upgrade has no equivalent guard, so a protected bundle can be hot-swapped to a new version even though it can't be uninstalled.

This is probably intentional — protected bundles shouldn't be frozen out of security updates — but worth an explicit decision and a one-line code comment so a future contributor doesn't "fix" it by adding the check (or vice versa).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — protected bundles can't be uninstalled but should receive version updates (security patches). Added code comment in upgrade() explaining the decision so future contributors don't accidentally add/remove the guard.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale bundle-contributed automations after upgrade.

syncBundleAutomations is idempotent on automationId (see L625 — createAutomation returns existing if the id matches) and never removes automations that existed for the prior version but were dropped in the new manifest.

If v1 declares schedules ["a", "b"] and v2 declares only ["a"], schedule b keeps running after upgrade with a stale prompt, until the bundle is fully uninstalled.

Fix: call removeBundleAutomations(name, registry) before syncBundleAutomations, or compute a name-set diff and delete the obsolete ones.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. removeBundleAutomations(name, registry) now called before syncBundleAutomations() in upgrade, matching the uninstall pattern. Stale schedules from dropped manifest entries are cleaned up.


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 -------------------------------------------

/**
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/bundles/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -267,4 +269,5 @@ export interface AppInfo {
toolCount: number;
trustScore: number;
ui: BundleUiMeta | null;
installSource?: "registry" | "local" | "remote";
}
1 change: 1 addition & 0 deletions src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export type EngineEventType =
| "context.assembled"
| "bundle.installed"
| "bundle.uninstalled"
| "bundle.upgraded"
| "bundle.crashed"
| "bundle.recovered"
| "bundle.dead"
Expand Down
1 change: 1 addition & 0 deletions src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,7 @@ export class Runtime {
toolCount,
trustScore: instance.trustScore ?? 0,
ui: instance.ui,
installSource: instance.installSource,
});
}
return apps;
Expand Down
124 changes: 121 additions & 3 deletions src/tools/system-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -899,6 +904,119 @@ async function uninstallBundleFromWorkspaceViaCtx(
}
}

async function upgradeBundle(
name: string,
wsId: string,
lifecycle: BundleLifecycleManager,
registry: ToolRegistry,
): Promise<ToolResult> {
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<ToolResult> => {
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=<bundle>\` 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<string, string[]>();
for (const tool of all) {
Expand Down
Loading