Skip to content
Merged
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
21 changes: 18 additions & 3 deletions App/shell/desktop/src/main/runtime-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,21 @@ async function installBundledMemoryRuntime(
throw new Error(`Bundled Memory installer is missing: ${cliEntry}`);
}
const executable = options.runtimeExecutable ?? process.execPath;
await runBundledMemoryCli(runtimeDirectory, runtimeConfig, options, [
await runBundledMemoryCli(
runtimeDirectory,
runtimeConfig,
options,
bundledMemoryInstallArguments(runtimeDirectory, runtimeConfig, memmyConfigPreexisting, executable)
);
}

export function bundledMemoryInstallArguments(
runtimeDirectory: string,
runtimeConfig: PackagedRuntimeConfig,
memmyConfigPreexisting: boolean,
executable: string
): string[] {
return [
"install",
"--service-only",
"--runtime-directory", runtimeDirectory,
Expand All @@ -850,8 +864,9 @@ async function installBundledMemoryRuntime(
"--memmy-config-preexisting", String(memmyConfigPreexisting),
"--node-executable", executable,
"--non-interactive",
"--use-compatible-installed"
]);
"--use-compatible-installed",
"--health-check-timeout-ms", String(MEMORY_STARTUP_TIMEOUT_MS)
];
}

async function runBundledMemoryCli(
Expand Down
25 changes: 25 additions & 0 deletions App/shell/desktop/tests/runtime-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import YAML from "yaml";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AgentGatewaySupervisor,
bundledMemoryInstallArguments,
ensureMemoryService,
preparePackagedBrowser,
preparePackagedRuntimeConfig,
Expand Down Expand Up @@ -156,6 +157,30 @@ describe("packaged desktop runtime config", () => {
]);
});

it("passes the finite Desktop startup budget to the bundled Memory installer", () => {
const args = bundledMemoryInstallArguments(
"/resources/memory",
{
configPath: "/memmy/config.yaml",
agentWorkspace: "/memmy/workspace",
memoryDatabasePath: "/memmy/memory.sqlite",
memoryBaseUrl: "http://127.0.0.1:18960",
memoryToken: "memory-token",
memoryListenHost: "127.0.0.1",
memoryListenPort: 18960,
agentGatewayBaseUrl: "http://127.0.0.1:18980",
agentGatewayHealthHost: "127.0.0.1",
agentGatewayHealthPort: 18970,
agentGatewayBootstrapSecret: "gateway-secret"
},
true,
"/runtime/node"
);

expect(args.slice(-2)).toEqual(["--health-check-timeout-ms", "120000"]);
expect(args).not.toContain("--skip-health-check");
});

it("rejects when the packaged migration command exits unsuccessfully", async () => {
const root = await makeTempRoot();
const child = Object.assign(new EventEmitter(), {
Expand Down
15 changes: 15 additions & 0 deletions Memory/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ function setupOptions(parsed: ParsedArgs): MemoryCliSetupOptions {
preferInstalledCompatible: optionBoolean(parsed.options, "use-compatible-installed"),
skipServiceRegistration: optionBoolean(parsed.options, "skip-service-registration"),
skipHealthCheck: optionBoolean(parsed.options, "skip-health-check"),
healthCheckTimeoutMs: positiveIntegerOption(parsed, "health-check-timeout-ms"),
configSource: legacyConfigSource(optionString(parsed.options, "config-source")),
legacyRoot: optionString(parsed.options, "legacy-root"),
nonInteractive: optionBoolean(parsed.options, "non-interactive"),
Expand All @@ -537,6 +538,19 @@ function legacyConfigSource(value: string | undefined): "openclaw" | "hermes" |
throw new Error("--config-source must be openclaw or hermes");
}

function positiveIntegerOption(parsed: ParsedArgs, name: string): number | undefined {
if (!hasOption(parsed.options, name)) return undefined;
const value = optionString(parsed.options, name);
if (value === undefined || !/^\d+$/.test(value)) {
throw new Error(`--${name} must be a positive integer`);
}
const parsedValue = Number(value);
if (!Number.isSafeInteger(parsedValue) || parsedValue <= 0) {
throw new Error(`--${name} must be a positive integer`);
}
return parsedValue;
}

function stringArrayOption(parsed: ParsedArgs, name: string): string[] | undefined {
const value = optionString(parsed.options, name);
if (value === undefined) return undefined;
Expand Down Expand Up @@ -623,6 +637,7 @@ function helpText(): string {
" --user-id <id> Memory namespace user id",
" --source <agent> Calling agent/source id",
" --config <path> Memmy config path",
" --health-check-timeout-ms <ms> Activation health timeout for Memory install",
" --skip-agent-skills Initialize config without installing agent skills",
" --config-source <agent> Select openclaw or hermes legacy config",
" --help, -h Show this help",
Expand Down
105 changes: 94 additions & 11 deletions Memory/src/cli/runtime-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MEMORY_PROTOCOL_VERSION, MEMORY_SERVICE_VERSION } from "../version.js";
const DEFAULT_RELEASES_URL = "https://github.com/MemTensor/memmy-agent/releases";
const INSTALL_LOCK_TIMEOUT_MS = 15_000;
const SERVICE_STOP_TIMEOUT_MS = 5_000;
export const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 120_000;

export interface RuntimeAssetDescriptor { name: string; sha256: string; size?: number; url?: string; }
export interface MemoryReleaseManifest {
Expand All @@ -33,6 +34,8 @@ export interface MemoryRuntimeInstallOptions {
nodeExecutable?: string;
skipServiceRegistration?: boolean;
skipHealthCheck?: boolean;
/** Maximum time to wait for the newly activated service to report its version. */
healthCheckTimeoutMs?: number;
endpoint?: string;
agents?: string[];
/** Desktop uses a newer compatible installation instead of replacing it with its bundled copy. */
Expand All @@ -50,6 +53,7 @@ export interface InstalledRuntimePointer {
}

export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions = {}): Promise<Record<string, unknown>> {
const healthCheckTimeoutMs = resolveHealthCheckTimeoutMs(options.healthCheckTimeoutMs);
const home = resolveHome(options.home ?? "~/.memmy");
const serviceHome = join(home, "memory-service");
const runtimeRoot = join(serviceHome, "runtime");
Expand All @@ -61,10 +65,11 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions
throw new Error(`Memory protocol ${manifest.protocolVersion} is incompatible with installer protocol ${MEMORY_PROTOCOL_VERSION}`);
}
const currentPath = join(serviceHome, "current.json");
const installationPath = join(serviceHome, "installation.json");
const previous = await readJsonFile<InstalledRuntimePointer>(currentPath);
const versionComparison = previous ? compareVersions(manifest.version, previous.version) : 1;
if (previous && options.preferInstalledCompatible && previous.protocolVersion === MEMORY_PROTOCOL_VERSION && versionComparison <= 0) {
return reuseInstalledRuntime(previous, home, serviceHome, options);
return reuseInstalledRuntime(previous, home, serviceHome, options, healthCheckTimeoutMs);
}
if (previous && versionComparison < 0) {
throw new Error(`refusing to downgrade Memory from ${previous.version} to ${manifest.version}`);
Expand All @@ -87,6 +92,7 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions
await mkdir(runtimeRoot, { recursive: true });
const installLock = await acquireInstallLock(join(serviceHome, "install.lock"));
let stagedPath: string | undefined;
let installedRuntimeCreated = false;
try {
if (!existsSync(pointer.entrypoint)) {
stagedPath = join(runtimeRoot, `.staging-${process.pid}-${Date.now()}`);
Expand All @@ -108,6 +114,7 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions
await mkdir(dirname(runtimeDir), { recursive: true });
await rm(runtimeDir, { recursive: true, force: true });
await rename(unpacked, runtimeDir);
installedRuntimeCreated = true;
} else {
await validateRuntime(runtimeDir, manifest.version, target, manifest.protocolVersion);
}
Expand All @@ -120,21 +127,33 @@ export async function installMemoryRuntime(options: MemoryRuntimeInstallOptions

if (!options.skipHealthCheck) {
try {
await waitForRuntimeHealth(options.endpoint ?? "http://127.0.0.1:18960", manifest.version);
await waitForRuntimeHealth(
options.endpoint ?? "http://127.0.0.1:18960",
manifest.version,
healthCheckTimeoutMs
);
} catch (error) {
if (!options.skipServiceRegistration) stopUserService();
if (!options.skipServiceRegistration && previous) stopUserService();
if (previous) {
await writeJsonAtomic(currentPath, previous);
await writeStableLauncher(home, serviceHome, previous.runtimeExecutable ?? process.execPath);
if (!options.skipServiceRegistration) registerAndStartUserService(home, serviceHome);
} else {
await unlink(currentPath).catch(() => undefined);
await cleanupFailedFirstInstall({
currentPath,
installationPath,
launcher,
runtimeDir,
runtimeCreated: installedRuntimeCreated,
serviceHome,
unregisterService: !options.skipServiceRegistration
});
}
throw error;
}
}

await writeJsonAtomic(join(serviceHome, "installation.json"), {
await writeJsonAtomic(installationPath, {
serviceVersion: manifest.version,
protocolVersion: manifest.protocolVersion,
target,
Expand Down Expand Up @@ -334,7 +353,8 @@ async function reuseInstalledRuntime(
pointer: InstalledRuntimePointer,
home: string,
serviceHome: string,
options: MemoryRuntimeInstallOptions
options: MemoryRuntimeInstallOptions,
healthCheckTimeoutMs: number
): Promise<Record<string, unknown>> {
if (options.dryRun) return { ok: true, reused: true, dryRun: true, ...pointer };
await validateRuntime(pointer.runtimeDir, pointer.version, pointer.target, pointer.protocolVersion);
Expand All @@ -344,7 +364,11 @@ async function reuseInstalledRuntime(
}
if (!options.skipServiceRegistration) registerAndStartUserService(home, serviceHome);
if (!options.skipHealthCheck) {
await waitForRuntimeHealth(options.endpoint ?? "http://127.0.0.1:18960", pointer.version);
await waitForRuntimeHealth(
options.endpoint ?? "http://127.0.0.1:18960",
pointer.version,
healthCheckTimeoutMs
);
}
return { ok: true, reused: true, ...pointer };
}
Expand Down Expand Up @@ -588,12 +612,19 @@ function runLifecycle(command: string, args: string[], allowFailure = false): vo
}
}

async function waitForRuntimeHealth(endpoint: string, expectedVersion: string): Promise<void> {
const deadline = Date.now() + 15_000;
async function waitForRuntimeHealth(
endpoint: string,
expectedVersion: string,
timeoutMs: number
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError = "service did not respond";
while (Date.now() < deadline) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
const requestTimeoutMs = Math.max(1, Math.min(1_000, remainingMs));
try {
const response = await fetch(`${endpoint.replace(/\/$/, "")}/api/v1/health`, { signal: AbortSignal.timeout(1_000) });
const response = await fetch(`${endpoint.replace(/\/$/, "")}/api/v1/health`, { signal: AbortSignal.timeout(requestTimeoutMs) });
if (response.ok) {
const health = await response.json() as Record<string, unknown>;
if (
Expand All @@ -612,10 +643,62 @@ async function waitForRuntimeHealth(endpoint: string, expectedVersion: string):
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
const delayMs = Math.min(250, Math.max(0, deadline - Date.now()));
if (delayMs <= 0) break;
await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
}
throw new Error(`Memory ${expectedVersion} failed its activation health check: ${lastError}`);
}

async function cleanupFailedFirstInstall(input: {
currentPath: string;
installationPath: string;
launcher: { command: string; script: string };
runtimeDir: string;
runtimeCreated: boolean;
serviceHome: string;
unregisterService: boolean;
}): Promise<void> {
if (input.unregisterService) {
await removeUserServiceRegistration();
}
await Promise.all([
rm(input.currentPath, { force: true }).catch(() => undefined),
rm(input.launcher.command, { force: true }).catch(() => undefined),
rm(input.launcher.script, { force: true }).catch(() => undefined),
rm(input.installationPath, { force: true }).catch(() => undefined),
rm(join(input.serviceHome, "runtime.json"), { force: true }).catch(() => undefined),
...(input.runtimeCreated
? [rm(input.runtimeDir, { recursive: true, force: true }).catch(() => undefined)]
: [])
]);
}

async function removeUserServiceRegistration(): Promise<void> {
if (process.platform === "darwin") {
runLifecycle("launchctl", ["bootout", `gui/${process.getuid?.() ?? 0}/com.memtensor.memmy-memory`], true);
await rm(join(homedir(), "Library", "LaunchAgents", "com.memtensor.memmy-memory.plist"), { force: true }).catch(() => undefined);
return;
}
if (process.platform === "linux") {
runLifecycle("systemctl", ["--user", "disable", "--now", "memmy-memory.service"], true);
await rm(join(homedir(), ".config", "systemd", "user", "memmy-memory.service"), { force: true }).catch(() => undefined);
runLifecycle("systemctl", ["--user", "daemon-reload"], true);
return;
}
if (process.platform === "win32") {
runLifecycle("schtasks", ["/End", "/TN", "Memmy Memory Service"], true);
runLifecycle("schtasks", ["/Delete", "/TN", "Memmy Memory Service", "/F"], true);
}
}

function resolveHealthCheckTimeoutMs(value: number | undefined): number {
if (value === undefined) return DEFAULT_HEALTH_CHECK_TIMEOUT_MS;
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error("healthCheckTimeoutMs must be a positive integer");
}
return value;
}
async function acquireInstallLock(path: string): Promise<{ release(): Promise<void> }> {
await mkdir(dirname(path), { recursive: true });
const startedAt = Date.now();
Expand Down
29 changes: 29 additions & 0 deletions Memory/tests/cli-command-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("memmy CLI command map", () => {
expect(help).toContain("init --agent codex");
expect(help).toContain("init --skip-agent-skills");
expect(help).toContain("--skip-agent-skills");
expect(help).toContain("--health-check-timeout-ms");
expect(help).toContain("Supported agents:");
expect(help).toContain("Default URL:");
});
Expand All @@ -39,6 +40,34 @@ describe("memmy CLI command map", () => {
await expect(runCommand({ argv: ["-v"] })).resolves.toBe(PROJECT_VERSION);
});

it("passes a valid installer health timeout and rejects malformed values", async () => {
const root = mkdtempSync(join(tmpdir(), "memmy-cli-timeout-"));
roots.push(root);
await expect(runCommand({
argv: [
"install",
"--dry-run",
"--service-only",
"--home", root,
"--health-check-timeout-ms", "1234"
]
})).resolves.toMatchObject({
ok: true,
command: "install",
runtime: { ok: true, dryRun: true }
});

await expect(runCommand({
argv: [
"install",
"--dry-run",
"--service-only",
"--home", root,
"--health-check-timeout-ms", "0"
]
})).rejects.toThrow("--health-check-timeout-ms must be a positive integer");
});

it("supports memmy-memory stop as an alias for service stop", async () => {
const stop = vi.fn(async (home: string) => ({ ok: true, action: "stop", home }));

Expand Down
Loading
Loading