From d47101ab4cd3dd648773f06cfdc7dcd9e0a99e8f Mon Sep 17 00:00:00 2001 From: Lingxuan Zuo Date: Fri, 1 May 2026 15:26:11 +0800 Subject: [PATCH] feat: gate Dreams and add start control (#953) --- README.ja-JP.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- electron/gateway/config-sync.ts | 12 +- electron/gateway/manager.ts | 66 ++++- electron/gateway/skills-symlink-cleanup.ts | 247 ++++++++++++++++-- electron/utils/openclaw-auth.ts | 237 +++++++++++++++-- .../scenarios/gateway-startup-diagnostics.md | 247 ++++++++++++++++++ src/App.tsx | 5 +- src/components/layout/Sidebar.tsx | 4 +- src/i18n/locales/en/dreams.json | 7 +- src/i18n/locales/ja/dreams.json | 7 +- src/i18n/locales/ru/dreams.json | 7 +- src/i18n/locales/zh/dreams.json | 7 +- src/lib/api-client.ts | 4 +- src/pages/Dreams/index.tsx | 131 ++++++++-- tests/e2e/developer-mode.spec.ts | 8 + tests/e2e/openclaw-dreams.spec.ts | 107 +++++++- tests/unit/api-client.test.ts | 26 +- tests/unit/gateway-manager-heartbeat.test.ts | 36 +++ tests/unit/gateway-ready-fallback.test.ts | 44 +++- tests/unit/openclaw-auth.test.ts | 148 +++++++++++ tests/unit/skills-symlink-cleanup.test.ts | 139 +++++++++- 23 files changed, 1380 insertions(+), 115 deletions(-) create mode 100644 harness/specs/scenarios/gateway-startup-diagnostics.md diff --git a/README.ja-JP.md b/README.ja-JP.md index 2f5fd5d..0329f4b 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -90,7 +90,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています 私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。 -サイドバーにはネイティブの Dreams ページもあり、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。 +開発者モードを有効にすると、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。 --- diff --git a/README.md b/README.md index cab74be..5de245c 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases. -The sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed. +When Developer Mode is enabled, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index df36698..5d80a76 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,7 +91,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。 -侧边栏还提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。 +打开开发者模式后,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。 --- diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 0e9e8af..2de504b 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -28,7 +28,7 @@ import { logger } from '../utils/logger'; import { prependPathEntry } from '../utils/env-path'; import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install'; import { stripSystemdSupervisorEnv } from './config-sync-env'; -import { cleanupAgentsSymlinkedSkills } from './skills-symlink-cleanup'; +import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup'; export interface GatewayLaunchContext { @@ -315,6 +315,16 @@ export async function syncGatewayConfigBeforeLaunch( logger.warn('Failed to clean .agents/skills-targeted skill symlinks:', err); } + // Remove stale OpenClaw runtime-deps cache roots that point at an older + // worktree/package. Those symlink trees can make Gateway plugin setup spend + // a long time in synchronous fs.open/copy calls before the RPC router is + // responsive. + try { + cleanupStalePluginRuntimeDeps(); + } catch (err) { + logger.warn('Failed to clean stale OpenClaw plugin runtime deps:', err); + } + // Auto-upgrade installed plugins before Gateway starts so that // the plugin manifest ID matches what sanitize wrote to the config. // Only install/upgrade plugins for channels that are actually configured diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index df6e40c..bcfa026 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -138,6 +138,7 @@ export class GatewayManager extends EventEmitter { private readonly restartController = new GatewayRestartController(); private readonly restartGovernor = new GatewayRestartGovernor(); private reloadDebounceTimer: NodeJS.Timeout | null = null; + private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null; private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY }; private reloadPolicyLoadedAt = 0; private reloadPolicyRefreshPromise: Promise | null = null; @@ -156,6 +157,7 @@ export class GatewayManager extends EventEmitter { private static readonly HEARTBEAT_MAX_MISSES_WIN = 5; public static readonly RESTART_COOLDOWN_MS = 5_000; private static readonly GATEWAY_READY_FALLBACK_MS = 30_000; + private static readonly INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS = 5 * 60_000; private lastRestartAt = 0; /** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */ private isAutoReconnectStart = false; @@ -197,6 +199,7 @@ export class GatewayManager extends EventEmitter { this.on('gateway:ready', () => { this.clearGatewayReadyFallback(); + this.clearInitialReadyHeartbeatRecoveryTimer(); if (this.status.state === 'running' && !this.status.gatewayReady) { logger.info('Gateway subsystems ready (event received)'); this.setStatus({ gatewayReady: true }); @@ -730,6 +733,7 @@ export class GatewayManager extends EventEmitter { this.reloadDebounceTimer = null; } this.clearGatewayReadyFallback(); + this.clearInitialReadyHeartbeatRecoveryTimer(); } private clearGatewayReadyFallback(): void { @@ -743,11 +747,28 @@ export class GatewayManager extends EventEmitter { this.clearGatewayReadyFallback(); this.gatewayReadyFallbackTimer = setTimeout(() => { this.gatewayReadyFallbackTimer = null; + void this.probeGatewayReadyFallback(); + }, GatewayManager.GATEWAY_READY_FALLBACK_MS); + } + + private async probeGatewayReadyFallback(): Promise { + if (this.status.state !== 'running' || this.status.gatewayReady) { + return; + } + + logger.info('Gateway ready fallback triggered; probing RPC router before marking ready'); + try { + await this.rpc('system-presence', {}, 5_000); if (this.status.state === 'running' && !this.status.gatewayReady) { - logger.info('Gateway ready fallback triggered (no gateway.ready event within timeout)'); + logger.info('Gateway ready fallback RPC router probe succeeded'); this.setStatus({ gatewayReady: true }); } - }, GatewayManager.GATEWAY_READY_FALLBACK_MS); + } catch (error) { + logger.warn('Gateway ready fallback RPC router probe failed; waiting for gateway.ready event or heartbeat recovery:', error); + if (this.status.state === 'running' && !this.status.gatewayReady) { + this.scheduleGatewayReadyFallback(); + } + } } /** @@ -834,6 +855,7 @@ export class GatewayManager extends EventEmitter { } private recordGatewayAlive(): void { + this.clearInitialReadyHeartbeatRecoveryTimer(); this.diagnostics.lastAliveAt = Date.now(); this.diagnostics.consecutiveHeartbeatMisses = 0; } @@ -1083,6 +1105,15 @@ export class GatewayManager extends EventEmitter { logger.warn(`Gateway heartbeat recovery skipped (${reason})`); return; } + const initialReadyRecoveryDelayMs = this.getInitialReadyHeartbeatRecoveryDelayMs(); + if (initialReadyRecoveryDelayMs > 0) { + logger.warn( + `Gateway heartbeat recovery deferred while waiting for initial gateway.ready ` + + `(retryAfterMs=${initialReadyRecoveryDelayMs})`, + ); + this.scheduleInitialReadyHeartbeatRecovery(initialReadyRecoveryDelayMs); + return; + } logger.warn('Gateway heartbeat recovery: restarting unresponsive gateway process'); void this.restart().catch((error) => { logger.warn('Gateway heartbeat recovery failed:', error); @@ -1091,6 +1122,37 @@ export class GatewayManager extends EventEmitter { }); } + private getInitialReadyHeartbeatRecoveryDelayMs(now = Date.now()): number { + if (this.status.gatewayReady || !this.status.connectedAt) return 0; + const connectedForMs = Math.max(0, now - this.status.connectedAt); + return Math.max(0, GatewayManager.INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS - connectedForMs); + } + + private scheduleInitialReadyHeartbeatRecovery(delayMs: number): void { + if (this.initialReadyHeartbeatRecoveryTimer) return; + this.initialReadyHeartbeatRecoveryTimer = setTimeout(() => { + this.initialReadyHeartbeatRecoveryTimer = null; + if ( + process.platform === 'win32' + || !this.shouldReconnect + || this.status.state !== 'running' + || this.status.gatewayReady + ) { + return; + } + logger.warn('Gateway heartbeat recovery: initial gateway.ready grace expired, restarting unresponsive gateway process'); + void this.restart().catch((error) => { + logger.warn('Gateway heartbeat recovery failed:', error); + }); + }, delayMs); + } + + private clearInitialReadyHeartbeatRecoveryTimer(): void { + if (!this.initialReadyHeartbeatRecoveryTimer) return; + clearTimeout(this.initialReadyHeartbeatRecoveryTimer); + this.initialReadyHeartbeatRecoveryTimer = null; + } + /** * Schedule reconnection attempt with exponential backoff */ diff --git a/electron/gateway/skills-symlink-cleanup.ts b/electron/gateway/skills-symlink-cleanup.ts index ed4d39d..d36a3e4 100644 --- a/electron/gateway/skills-symlink-cleanup.ts +++ b/electron/gateway/skills-symlink-cleanup.ts @@ -1,5 +1,5 @@ /** - * Pre-launch cleanup for stray skill symlinks under ~/.openclaw/skills. + * Pre-launch cleanup for stray skill symlinks under OpenClaw skill roots. * * Background: since openclaw commit 253e159700 ("fix: harden workspace skill * path containment"), the Gateway rejects any candidate under a skills root @@ -8,23 +8,26 @@ * reason=symlink-escape source=openclaw-managed ...` * warning per offending entry on every start. * - * A common offender is one-shot install scripts that drop symlinks into - * ~/.openclaw/skills/ pointing at ~/.agents/skills/. The skills - * still load via the separate `agents-skills-personal` source (which scans - * ~/.agents/skills directly), so the symlinks under ~/.openclaw/skills are - * pure log noise — and a duplicate entry that the loader can never accept. + * Common offenders are one-shot install scripts that drop symlinks into: + * - ~/.openclaw/skills/ -> ~/.agents/skills/ + * - ~/.openclaw/workspace/skills/ -> ~/.openclaw/workspace/.agents/skills/ + * - ~/.openclaw/skills/ -> ~/workspace//skills/ + * The hardened loader rejects these because their realpath escapes the + * configured managed root, so they are pure log noise — entries that the + * loader can never accept from this root. * * This helper is invoked before each Gateway launch to remove those * specific symlinks. Scope is intentionally narrow: - * - source dir: ~/.openclaw/skills (resolved via getOpenClawSkillsDir()) - * - target dir: ~/.agents/skills only (NOT the broader ~/.agents tree) - * Symlinks whose realpath resolves anywhere else under ~/.agents (e.g. - * ~/.agents/tools/foo) or to unrelated locations are left untouched. + * - source dirs: ~/.openclaw/skills and ~/.openclaw/workspace/skills + * - target dirs: anything outside the matching managed skills root + * Symlinks whose realpath stays inside the same managed skills root are left + * untouched. * - * Removal uses fs.rmSync({ force: true }) rather than fs.unlinkSync so that - * Windows directory symlinks and junctions (the form that non-admin Windows - * installs end up creating) are deleted correctly. unlinkSync raises EPERM - * on those on Windows. + * Removal uses fs.rmSync({ force: true, recursive: true }) rather than + * fs.unlinkSync so that directory symlinks and Windows junctions (the form + * that non-admin Windows installs end up creating) are deleted correctly. + * unlinkSync raises EPERM on those on Windows, and rmSync without recursive + * can reject directory symlinks on some platforms. * * This is a transitional workaround. Once openclaw/openclaw#59219 lands and * the loader stops rejecting managed-source symlinks whose realpath escapes @@ -33,6 +36,7 @@ import { existsSync, lstatSync, + readlinkSync, readdirSync, realpathSync, rmSync, @@ -40,14 +44,18 @@ import { } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; -import { getOpenClawSkillsDir } from '../utils/paths'; +import { getOpenClawConfigDir, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../utils/paths'; import { logger } from '../utils/logger'; export interface CleanupOptions { /** Override for ~/.openclaw/skills (mainly for tests). */ skillsDir?: string; - /** Override for ~/.agents/skills (mainly for tests). */ + /** Override for ~/.agents/skills (mainly for tests/log context). */ agentsDir?: string; + /** Override for ~/.openclaw/workspace/skills (mainly for tests). */ + workspaceSkillsDir?: string; + /** Override for ~/.openclaw/workspace/.agents/skills (mainly for tests). */ + workspaceAgentsDir?: string; } export interface CleanupResult { @@ -57,6 +65,13 @@ export interface CleanupResult { examined: number; } +export interface PluginRuntimeDepsCleanupOptions { + /** Override for ~/.openclaw/plugin-runtime-deps (mainly for tests). */ + runtimeDepsDir?: string; + /** Override for the current bundled OpenClaw package dir (mainly for tests). */ + currentOpenClawDir?: string; +} + function defaultSkillsDir(): string { return getOpenClawSkillsDir(); } @@ -65,6 +80,18 @@ function defaultAgentsDir(): string { return path.join(homedir(), '.agents', 'skills'); } +function defaultWorkspaceSkillsDir(): string { + return path.join(getOpenClawConfigDir(), 'workspace', 'skills'); +} + +function defaultWorkspaceAgentsDir(): string { + return path.join(getOpenClawConfigDir(), 'workspace', '.agents', 'skills'); +} + +function defaultPluginRuntimeDepsDir(): string { + return path.join(getOpenClawConfigDir(), 'plugin-runtime-deps'); +} + /** * Resolve the agents skills directory to its real path. When the directory * itself does not exist yet (fresh install), fall back to realpath'ing its @@ -106,11 +133,180 @@ function isInside(parent: string, child: string): boolean { return !rel.startsWith('..') && !path.isAbsolute(rel); } +function resolveSymlinkTarget(linkPath: string): string | null { + try { + const target = readlinkSync(linkPath); + return path.resolve(path.dirname(linkPath), target); + } catch { + return null; + } +} + +function looksLikeOpenClawPackagePath(candidate: string): boolean { + const normalized = candidate.replace(/\\/g, '/'); + return /\/node_modules(?:\/\.pnpm\/[^/]+\/node_modules)?\/openclaw(?:\/|$)/.test(normalized); +} + +function resolveCurrentOpenClawRoots(currentOpenClawDir: string): string[] { + const roots = new Set([path.resolve(currentOpenClawDir)]); + try { + roots.add(realpathSync(currentOpenClawDir)); + } catch { + // fall through + } + return Array.from(roots); +} + export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): CleanupResult { - const skillsDir = opts.skillsDir ?? defaultSkillsDir(); - const agentsDir = opts.agentsDir ?? defaultAgentsDir(); + const hasMainOverrides = opts.skillsDir !== undefined || opts.agentsDir !== undefined; + const hasWorkspaceOverrides = + opts.workspaceSkillsDir !== undefined || opts.workspaceAgentsDir !== undefined; + const roots = [ + { + skillsDir: opts.skillsDir ?? defaultSkillsDir(), + agentsDir: opts.agentsDir ?? defaultAgentsDir(), + }, + ]; + + if (!hasMainOverrides || hasWorkspaceOverrides) { + roots.push({ + skillsDir: opts.workspaceSkillsDir ?? defaultWorkspaceSkillsDir(), + agentsDir: opts.workspaceAgentsDir ?? defaultWorkspaceAgentsDir(), + }); + } + + const result: CleanupResult = { removed: [], examined: 0 }; + const seenRoots = new Set(); + + for (const root of roots) { + const rootKey = `${path.resolve(root.skillsDir)}\0${path.resolve(root.agentsDir)}`; + if (seenRoots.has(rootKey)) continue; + seenRoots.add(rootKey); + + const rootResult = cleanupSkillsDir(root.skillsDir, root.agentsDir); + result.removed.push(...rootResult.removed); + result.examined += rootResult.examined; + } + + return result; +} + +/** + * Remove stale OpenClaw plugin runtime dependency cache roots. + * + * OpenClaw can materialize `~/.openclaw/plugin-runtime-deps/openclaw-*` as a + * symlink tree back into the package's `dist` files. After app upgrades or + * worktree switches those symlinks can point at an old `node_modules/openclaw` + * path. The Gateway may then spend a long time synchronously opening/copying + * old runtime files during plugin setup, which blocks RPC readiness. + * + * Scope is intentionally narrow: only immediate cache roots named `openclaw-*` + * are removed, and only when a symlink inside points at an OpenClaw package + * path outside the current bundled package. The cache is regenerated by + * OpenClaw on demand. + */ +export function cleanupStalePluginRuntimeDeps( + opts: PluginRuntimeDepsCleanupOptions = {}, +): CleanupResult { + const runtimeDepsDir = opts.runtimeDepsDir ?? defaultPluginRuntimeDepsDir(); + const currentRoots = resolveCurrentOpenClawRoots(opts.currentOpenClawDir ?? getOpenClawResolvedDir()); const result: CleanupResult = { removed: [], examined: 0 }; + if (!existsSync(runtimeDepsDir)) { + return result; + } + + let entries: Dirent[]; + try { + entries = readdirSync(runtimeDepsDir, { withFileTypes: true, encoding: 'utf8' }); + } catch (err) { + logger.warn(`[plugin-runtime-deps-cleanup] Failed to list ${runtimeDepsDir}:`, err); + return result; + } + + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('openclaw-')) { + continue; + } + + const cacheRoot = path.join(runtimeDepsDir, entry.name); + const scan = scanRuntimeDepsRootForStaleOpenClawSymlink(cacheRoot, currentRoots); + result.examined += scan.examined; + if (!scan.stale) { + continue; + } + + try { + rmSync(cacheRoot, { force: true, recursive: true }); + result.removed.push(entry.name); + } catch (err) { + logger.warn(`[plugin-runtime-deps-cleanup] Failed to remove ${cacheRoot}:`, err); + } + } + + if (result.removed.length > 0) { + logger.info( + `[plugin-runtime-deps-cleanup] Removed ${result.removed.length} stale OpenClaw runtime cache root(s): ` + + result.removed.join(', '), + ); + } + + return result; +} + +function scanRuntimeDepsRootForStaleOpenClawSymlink( + cacheRoot: string, + currentOpenClawRoots: string[], +): { stale: boolean; examined: number } { + const stack = [cacheRoot]; + let examined = 0; + const maxEntries = 5000; + + while (stack.length > 0 && examined < maxEntries) { + const dir = stack.pop()!; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + continue; + } + + for (const entry of entries) { + if (examined >= maxEntries) break; + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(entryPath); + continue; + } + + let isSymlink = entry.isSymbolicLink(); + if (!isSymlink) { + try { + isSymlink = lstatSync(entryPath).isSymbolicLink(); + } catch { + continue; + } + } + if (!isSymlink) continue; + + examined++; + const target = resolveSymlinkTarget(entryPath); + if (!target || !looksLikeOpenClawPackagePath(target)) { + continue; + } + + const pointsAtCurrentOpenClaw = currentOpenClawRoots.some((root) => isInside(root, target)); + if (!pointsAtCurrentOpenClaw) { + return { stale: true, examined }; + } + } + } + + return { stale: false, examined }; +} + +function cleanupSkillsDir(skillsDir: string, agentsDir: string): CleanupResult { + const result: CleanupResult = { removed: [], examined: 0 }; if (!existsSync(skillsDir)) { return result; } @@ -124,6 +320,7 @@ export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): Cleanup } const agentsRealRoot = resolveAgentsRealRoot(agentsDir); + const skillsRealRoot = resolveAgentsRealRoot(skillsDir); for (const entry of entries) { const entryPath = path.join(skillsDir, entry.name); @@ -147,13 +344,13 @@ export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): Cleanup continue; } - if (!isInside(agentsRealRoot, realTarget)) continue; + if (isInside(skillsRealRoot, realTarget)) continue; try { - // rmSync({ force: true }) handles file symlinks, directory symlinks, - // and Windows junctions uniformly. unlinkSync would raise EPERM on - // directory symlinks/junctions on Windows. - rmSync(entryPath, { force: true }); + // rmSync handles file symlinks, directory symlinks, and Windows + // junctions uniformly. unlinkSync would raise EPERM on directory + // symlinks/junctions on Windows. + rmSync(entryPath, { force: true, recursive: true }); result.removed.push(entry.name); } catch (err) { logger.warn(`[skills-cleanup] Failed to remove ${entryPath}:`, err); @@ -163,14 +360,14 @@ export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): Cleanup if (result.removed.length > 0) { logger.info( `[skills-cleanup] Removed ${result.removed.length} stray skill symlink(s) ` + - `under ${skillsDir} that resolved into ${agentsRealRoot} ` + + `under ${skillsDir} that escaped managed root ${skillsRealRoot} ` + `(workaround for openclaw/openclaw#59219): ` + result.removed.join(', '), ); } else if (result.examined > 0) { logger.debug( `[skills-cleanup] Examined ${result.examined} symlink(s) under ${skillsDir}; ` + - `none resolved into ${agentsRealRoot}`, + `none escaped managed root (agents context: ${agentsRealRoot})`, ); } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 3133132..1dc4fcc 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -8,9 +8,9 @@ * equivalents could stall for 500 ms – 2 s+ per call, causing "Not * Responding" hangs. */ -import { access, mkdir, readFile, writeFile } from 'fs/promises'; +import { access, mkdir, readFile, readdir, writeFile } from 'fs/promises'; import { constants, readdirSync, readFileSync, existsSync } from 'fs'; -import { join } from 'path'; +import { dirname, join } from 'path'; import { homedir } from 'os'; import { listConfiguredAgentIds } from './agent-config'; import { getOpenClawResolvedDir } from './paths'; @@ -50,7 +50,11 @@ interface MiniMaxPluginRegistration extends OAuthPluginRegistration { } let _bundledPluginManifestCache: BundledPluginManifest[] | null = null; -let _bundledPluginCache: { all: Set; enabledByDefault: string[] } | null = null; +let _bundledPluginCache: { + all: Set; + enabledByDefault: string[]; + manifestsById: Map; +} | null = null; let _miniMaxPluginRegistrationCache: MiniMaxPluginRegistration | null = null; export function resetOpenClawPluginDiscoveryCaches(): void { @@ -445,6 +449,17 @@ const BUILTIN_CHANNEL_IDS = new Set([ 'mattermost', 'qqbot', ]); +const OPTIONAL_PROVIDER_LIKE_BUNDLED_PLUGIN_IDS = new Set([ + 'alibaba', + 'deepgram', + 'elevenlabs', + 'groq', + 'microsoft', + 'phone-control', + 'runway', + 'talk-voice', + 'voyage', +]); const AUTH_PROFILE_PROVIDER_KEY_MAP: Record = { 'openai-codex': 'openai', 'google-gemini-cli': 'google', @@ -472,6 +487,31 @@ function expandProviderKeysForDeletion(provider: string): string[] { return [provider, ...(AUTH_PROFILE_PROVIDER_KEY_REVERSE_MAP[provider] ?? [])]; } +function normalizePluginPathForCompare(pluginPath: string): string { + return pluginPath.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function isBundledOpenClawPluginPath(pluginPath: string): boolean { + const normalized = normalizePluginPathForCompare(pluginPath); + const currentDistExtensions = normalizePluginPathForCompare( + join(getOpenClawResolvedDir(), 'dist', 'extensions'), + ); + const currentLegacyExtensions = normalizePluginPathForCompare( + join(getOpenClawResolvedDir(), 'extensions'), + ); + + if ( + normalized === currentDistExtensions + || normalized.startsWith(`${currentDistExtensions}/`) + || normalized === currentLegacyExtensions + || normalized.startsWith(`${currentLegacyExtensions}/`) + ) { + return true; + } + + return /\/node_modules(?:\/\.pnpm\/[^/]+\/node_modules)?\/openclaw\/(?:dist\/)?extensions(?:\/|$)/.test(normalized); +} + /** * Scan OpenClaw's bundled extensions directory to find all plugins that have * `enabledByDefault: true` in their `openclaw.plugin.json` manifest. @@ -484,20 +524,26 @@ function expandProviderKeysForDeletion(provider: string): string[] { * Results are cached for the lifetime of the process since bundled * extensions don't change at runtime. */ -function discoverBundledPlugins(): { all: Set; enabledByDefault: string[] } { +function discoverBundledPlugins(): { + all: Set; + enabledByDefault: string[]; + manifestsById: Map; +} { if (_bundledPluginCache) return _bundledPluginCache; const all = new Set(); const enabledByDefault: string[] = []; + const manifestsById = new Map(); for (const manifest of discoverBundledPluginManifests()) { all.add(manifest.id); + manifestsById.set(manifest.id, manifest); if (manifest.enabledByDefault) { enabledByDefault.push(manifest.id); } } - _bundledPluginCache = { all, enabledByDefault }; + _bundledPluginCache = { all, enabledByDefault, manifestsById }; return _bundledPluginCache; } @@ -535,6 +581,38 @@ async function getProvidersFromAuthProfileStores(): Promise> { return providers; } +async function collectActiveProviderIdsFromConfig(config: Record): Promise> { + const activeProviders = new Set(); + const providers = (config.models as Record | undefined)?.providers; + if (providers && typeof providers === 'object') { + for (const key of Object.keys(providers as Record)) { + activeProviders.add(key); + } + } + + const agents = config.agents as Record | undefined; + const defaults = agents?.defaults as Record | undefined; + const modelConfig = defaults?.model as Record | undefined; + const primaryModel = typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined; + if (primaryModel?.includes('/')) { + activeProviders.add(primaryModel.split('/')[0]); + } + + const auth = config.auth as Record | undefined; + addProvidersFromProfileEntries(auth?.profiles as Record | undefined, activeProviders); + + const authProfileProviders = await getProvidersFromAuthProfileStores(); + for (const provider of authProfileProviders) { + activeProviders.add(provider); + } + + for (const deprecated of DEPRECATED_PROVIDER_IDS) { + activeProviders.delete(deprecated); + } + + return activeProviders; +} + async function readOpenClawJson(): Promise> { return (await readJsonFile>(OPENCLAW_CONFIG_PATH)) ?? {}; } @@ -551,6 +629,86 @@ async function resolveInstalledFeishuPluginId(): Promise { return null; } +async function discoverInstalledExtensionPluginIds(): Promise> { + const ids = new Set(); + const extensionRoot = join(homedir(), '.openclaw', 'extensions'); + + let entries: Awaited>; + try { + entries = await readdir(extensionRoot, { withFileTypes: true }); + } catch { + return ids; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = join(extensionRoot, entry.name, 'openclaw.plugin.json'); + const manifest = await readJsonFile<{ id?: unknown }>(manifestPath); + if (typeof manifest?.id === 'string' && manifest.id.trim()) { + ids.add(manifest.id.trim()); + } + } + + return ids; +} + +function collectPluginLoadPathsFromConfig(plugins: unknown): string[] { + const paths: string[] = []; + const pushPath = (value: unknown): void => { + if (typeof value === 'string' && value.trim()) { + paths.push(value); + } + }; + + if (Array.isArray(plugins)) { + for (const value of plugins) pushPath(value); + return paths; + } + + if (!isPlainRecord(plugins)) { + return paths; + } + + const load = plugins.load; + if (Array.isArray(load)) { + for (const value of load) pushPath(value); + } else if (isPlainRecord(load) && Array.isArray(load.paths)) { + for (const value of load.paths) pushPath(value); + } + + return paths; +} + +async function readPluginManifestIdFromPath(pluginPath: string): Promise { + const candidates = [ + join(pluginPath, 'openclaw.plugin.json'), + join(dirname(pluginPath), 'openclaw.plugin.json'), + ]; + + for (const manifestPath of candidates) { + const manifest = await readJsonFile<{ id?: unknown }>(manifestPath); + if (typeof manifest?.id === 'string' && manifest.id.trim()) { + return manifest.id.trim(); + } + } + + return null; +} + +async function discoverLoadedPluginIdsFromConfig(config: Record): Promise> { + const ids = new Set(); + const pluginPaths = collectPluginLoadPathsFromConfig(config.plugins); + + for (const pluginPath of pluginPaths) { + const pluginId = await readPluginManifestIdFromPath(pluginPath); + if (pluginId) { + ids.add(pluginId); + } + } + + return ids; +} + function normalizeAgentsDefaultsCompactionMode(config: Record): void { const agents = (config.agents && typeof config.agents === 'object' ? config.agents as Record @@ -1674,7 +1832,7 @@ export async function sanitizeOpenClawConfig(): Promise { const validPlugins: unknown[] = []; for (const p of plugins) { if (typeof p === 'string' && p.startsWith('/')) { - if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { + if (isBundledOpenClawPluginPath(p) || !(await fileExists(p))) { console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`); modified = true; } else { @@ -1691,7 +1849,7 @@ export async function sanitizeOpenClawConfig(): Promise { const validLoad: unknown[] = []; for (const p of pluginsObj.load) { if (typeof p === 'string' && p.startsWith('/')) { - if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { + if (isBundledOpenClawPluginPath(p) || !(await fileExists(p))) { console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`); modified = true; } else { @@ -1710,7 +1868,7 @@ export async function sanitizeOpenClawConfig(): Promise { const countBefore = loadObj.paths.length; for (const p of loadObj.paths) { if (typeof p === 'string' && p.startsWith('/')) { - if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { + if (isBundledOpenClawPluginPath(p) || !(await fileExists(p))) { console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from plugins.load.paths`); modified = true; } else { @@ -1721,7 +1879,14 @@ export async function sanitizeOpenClawConfig(): Promise { } } if (validPaths.length !== countBefore) { - loadObj.paths = validPaths; + if (validPaths.length > 0) { + loadObj.paths = validPaths; + } else { + delete loadObj.paths; + } + if (Object.keys(loadObj).length === 0) { + delete pluginsObj.load; + } } } } @@ -2112,12 +2277,26 @@ export async function sanitizeOpenClawConfig(): Promise { // Discover all bundled extension IDs and which ones are enabledByDefault // so we can (a) exclude them from the "external" set (prevents stale // entries surviving across OpenClaw upgrades) and (b) re-add the - // enabledByDefault ones to prevent the allowlist from blocking them. + // enabledByDefault ones that are non-provider plugins, explicitly + // configured, or needed by the active provider config. const bundled = discoverBundledPlugins(); + const installedExtensionIds = await discoverInstalledExtensionPluginIds(); + const loadedPluginIds = await discoverLoadedPluginIdsFromConfig(config); + const activeProviderIds = await collectActiveProviderIdsFromConfig(config); - const externalPluginIds = allowArr2.filter( - (pluginId) => !BUILTIN_CHANNEL_IDS.has(pluginId) && !bundled.all.has(pluginId), - ); + const externalPluginIds: string[] = []; + for (const pluginId of allowArr2) { + if (BUILTIN_CHANNEL_IDS.has(pluginId) || bundled.all.has(pluginId)) continue; + const isConfiguredExternal = Boolean(pEntries[pluginId]); + const isInstalledExternal = installedExtensionIds.has(pluginId); + const isLoadedExternal = loadedPluginIds.has(pluginId); + if (!isConfiguredExternal && !isInstalledExternal && !isLoadedExternal) { + console.log(`[sanitize] Removed missing external plugin from plugins.allow: ${pluginId}`); + modified = true; + continue; + } + externalPluginIds.push(pluginId); + } let nextAllow = [...externalPluginIds]; if (externalPluginIds.length > 0) { for (const channelId of configuredBuiltIns) { @@ -2131,11 +2310,25 @@ export async function sanitizeOpenClawConfig(): Promise { // ── Ensure enabledByDefault built-in plugins survive restrictive allowlists ── // OpenClaw's plugin enable logic checks the allowlist BEFORE enabledByDefault, - // so any bundled plugin with enabledByDefault: true (e.g. browser, diffs, etc.) - // gets blocked when plugins.allow is non-empty. We add them back here. - // On upgrade, plugins removed from enabledByDefault are also removed from the - // allowlist because they were excluded from externalPluginIds above. + // so any bundled plugin with enabledByDefault: true can get blocked when + // plugins.allow is non-empty. Re-adding every provider plugin makes + // Gateway startup mirror/copy many plugin runtime roots, which can block + // the Gateway event loop long enough for Dreams and health RPCs to time + // out. Keep always-on non-provider plugins plus provider plugins that + // the current config/auth profile actually uses. if (nextAllow.length > 0) { + for (const pluginId of Object.keys(pEntries)) { + if (!bundled.all.has(pluginId)) continue; + const entry = isPlainRecord(pEntries[pluginId]) ? pEntries[pluginId] as Record : {}; + if (entry.enabled === false) continue; + if (pluginId === 'feishu' && (!isFeishuConfigured || canonicalFeishuId !== 'feishu')) { + continue; + } + if (!nextAllow.includes(pluginId)) { + nextAllow.push(pluginId); + } + } + for (const pluginId of bundled.enabledByDefault) { // When feishu is not configured at all, or the official // openclaw-lark plugin replaces the built-in 'feishu' extension, @@ -2145,6 +2338,16 @@ export async function sanitizeOpenClawConfig(): Promise { if (pluginId === 'feishu' && (!isFeishuConfigured || canonicalFeishuId !== 'feishu')) { continue; } + const manifest = bundled.manifestsById.get(pluginId); + const providerIds = manifest?.providers ?? []; + const isConfiguredPlugin = Boolean(pEntries[pluginId]); + const isProviderPlugin = providerIds.length > 0 + || OPTIONAL_PROVIDER_LIKE_BUNDLED_PLUGIN_IDS.has(pluginId); + const isActiveProviderPlugin = providerIds.some((providerId) => activeProviderIds.has(providerId)) + || activeProviderIds.has(pluginId); + if (isProviderPlugin && !isConfiguredPlugin && !isActiveProviderPlugin) { + continue; + } if (!nextAllow.includes(pluginId)) { nextAllow.push(pluginId); } diff --git a/harness/specs/scenarios/gateway-startup-diagnostics.md b/harness/specs/scenarios/gateway-startup-diagnostics.md new file mode 100644 index 0000000..b323307 --- /dev/null +++ b/harness/specs/scenarios/gateway-startup-diagnostics.md @@ -0,0 +1,247 @@ +--- +id: gateway-startup-diagnostics +title: Gateway Startup Diagnostics +type: runtime-diagnostics +ownedPaths: + - electron/gateway/** + - electron/utils/openclaw-auth.ts + - electron/utils/paths.ts + - src/stores/gateway.ts + - src/pages/Dreams/** +requiredProfiles: + - fast + - comms +requiredRules: + - gateway-readiness-policy + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy + - comms-regression + - docs-sync +--- + +Use this spec when ClawX shows the Gateway as starting/running but UI data does not refresh, Dreams cannot load, or Gateway RPC calls time out after a restart. + +## Failure Shape + +Treat these as the same incident family until proven otherwise: + +- `Gateway ready fallback triggered; probing RPC router before marking ready` +- `Gateway ready fallback RPC router probe failed: RPC timeout: system-presence` +- `[gateway:rpc] doctor.memory.status failed` +- `[gateway:rpc] doctor.memory.dreamDiary failed` +- `chat.history unavailable during gateway startup` +- Port `18789` is listening, but Gateway HTTP or WebSocket RPC does not return. + +Important distinction: + +- **Port ready** only means the process is listening. +- **Handshake ready** only means ClawX connected to the Gateway socket. +- **RPC ready** means a cheap call such as `system-presence` succeeds. + +UI features that depend on Gateway runtime data must prefer RPC-ready evidence over port-ready evidence. + +## Fast Triage + +1. Confirm the process and ports: + +```bash +lsof -nP -iTCP:18789 -sTCP:LISTEN || true +lsof -nP -iTCP:5173 -sTCP:LISTEN || true +``` + +2. Read recent ClawX logs: + +```bash +tail -n 160 "$HOME/Library/Application Support/clawx/logs/clawx-$(date +%F).log" +``` + +3. Probe a low-cost RPC. Redirect output for memory-related calls because successful responses may contain user data: + +```bash +pnpm exec openclaw gateway call system-presence >/tmp/clawx-system-presence.json +pnpm exec openclaw gateway call doctor.memory.status >/tmp/clawx-memory-status.json +pnpm exec openclaw gateway call doctor.memory.dreamDiary >/tmp/clawx-dream-diary.json +``` + +4. If port is listening but RPC times out, agree on the sampling scope, then sample the Gateway process on macOS: + +```bash +sample 3 -mayDie >/tmp/clawx-gateway.sample.txt +``` + +Look for heavy main-thread stacks around `uv_fs_open`, `uv_fs_scandir`, `open`, `read`, `write`, `mkdir`, or repeated plugin/skill initialization frames. This usually means the Gateway event loop is busy with synchronous file work and cannot service RPCs yet. + +## Sampling Coordination Protocol + +Sampling is diagnostic work against a live local process. Coordinate it explicitly when the user is depending on the current Gateway for active work. + +Before sampling, state: + +- Which process will be sampled, including PID and why it is believed to be the Gateway. +- The sampling command and duration. Default to `sample 3 -mayDie`; increase duration only after explaining why. +- Expected impact. A short macOS sample is read-only and usually low impact, but it can produce a large file and may briefly add system load. +- Where the artifact will be written, usually `/tmp/clawx-gateway.sample.txt`. +- What will be inspected and what will not be shared verbatim. + +Do not proceed without explicit user agreement when: + +- Sampling a process that is not clearly the ClawX-owned Gateway child. +- Increasing sample duration above 5 seconds or repeating samples many times. +- Collecting process environment, open files, memory dumps, trace archives, or any artifact likely to contain secrets. +- Killing, restarting, or force-cleaning Gateway while active tasks, cron jobs, or user-visible work may be running. + +Safe-by-default commands: + +```bash +lsof -nP -iTCP:18789 -sTCP:LISTEN || true +ps -axo pid,ppid,etime,command | rg -i "openclaw-gateway|Electron|vite" | rg -v "rg -i" +sample 3 -mayDie >/tmp/clawx-gateway.sample.txt +``` + +Avoid by default: + +- `env`, `/proc//environ`, or full process command dumps that include tokens. +- Full memory dumps. +- Pasting raw `doctor.memory.*` output. +- Pasting complete sample files when only top stack signatures are needed. + +When analyzing a sample, report a compact summary: + +- Main-thread state: idle, synchronous fs I/O, network connect, CPU-bound JS, or unknown. +- Dominant stack signature, such as `uv_fs_open` under plugin runtime setup. +- Whether the finding points to ClawX-owned prelaunch cleanup, OpenClaw runtime startup cost, active user work, or inconclusive data. +- Recommended next action and whether it requires another user approval. + +## Known Causes + +### Stale Runtime Dependency Cache + +Symptoms: + +- Sample shows many synchronous `open` calls while plugin runtime setup is running. +- `~/.openclaw/plugin-runtime-deps/openclaw-*` contains symlink trees pointing at an old worktree or old `node_modules/openclaw`. +- Startup takes much longer than expected before RPC router becomes responsive. + +Expected mitigation: + +- `cleanupStalePluginRuntimeDeps()` runs before Gateway launch. +- It removes only immediate `openclaw-*` cache roots when symlinks inside point at an OpenClaw package path outside the current bundled package. +- It must not remove arbitrary third-party plugin caches. + +### Over-Broad Plugin Allowlist + +Symptoms: + +- `plugins.allow` contains many provider or media plugins that are not configured or active. +- Gateway mirrors or loads more runtime plugin roots than the current user setup needs. + +Expected mitigation: + +- Preserve external plugins that are installed, configured in `plugins.entries`, or loaded through `plugins.load` / `plugins.load.paths`. +- Preserve configured bundled plugins, active provider plugins, and core runtime plugins such as `browser`, `acpx`, `device-pair`, and `memory-core`. +- Do not re-add optional provider-like bundled plugins such as `alibaba`, `deepgram`, `elevenlabs`, `groq`, `microsoft`, `phone-control`, `runway`, `talk-voice`, or `voyage` unless configured or active. + +### Escaped Skill Symlinks + +Symptoms: + +- Logs repeatedly show `Skipping escaped skill path outside its configured root`. +- A managed root such as `~/.openclaw/skills` contains symlinks whose realpath points outside that root. + +Expected mitigation: + +- `cleanupAgentsSymlinkedSkills()` removes symlinks under OpenClaw managed skill roots whose realpath escapes the same root. +- Real directories and symlinks that stay inside the managed skills root must be preserved. +- This cleanup is safe because the hardened OpenClaw loader would reject those escaped entries anyway. + +### Startup Work Competing With RPC + +Symptoms: + +- Gateway handshake completes, but `system-presence`, `chat.history`, or `doctor.memory.*` times out during the first minutes. +- Logs mention cron repair, channel account checks, session lock cleanup, memory-core cron reconciliation, or active embedded/task runs. + +Expected behavior: + +- Do not mark Gateway fully ready from a pure timer fallback. +- The fallback must probe `system-presence` before emitting ready. +- Heartbeat recovery may defer restart during the initial grace window, but it should not loop restart while the Gateway is still performing startup work. + +### Restart Deferral By Active Work + +Symptoms: + +- Logs mention restart deferral because operations, embedded runs, or task runs are still active. +- A restart takes minutes even though the process is otherwise alive. + +Expected handling: + +- Explain to users that restart cost is dominated by active Gateway work, not by ClawX UI rendering. +- Avoid triggering full Gateway restart for feature toggles when a narrower config reload or plugin RPC is available. + +## Remediation Order + +1. Avoid renderer-side transport workarounds. Renderer code must continue to use `host-api` / `api-client`. +2. Preserve `~/.openclaw/openclaw.json`; never replace it with a skeleton on parse errors. +3. Run the startup sanitizers and cleanup hooks locally: + +```bash +pnpm exec tsx -e "import { sanitizeOpenClawConfig } from './electron/utils/openclaw-auth.ts'; import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './electron/gateway/skills-symlink-cleanup.ts'; sanitizeOpenClawConfig().then(() => { console.log(cleanupAgentsSymlinkedSkills()); console.log(cleanupStalePluginRuntimeDeps()); });" +``` + +4. Restart the app or Gateway and watch for the startup metric: + +```text +[metric] gateway.startup { + "configSyncMs": ..., + "spawnToReadyMs": ..., + "readyToConnectMs": ..., + "totalMs": ... +} +``` + +5. Confirm RPC readiness: + +```bash +pnpm exec openclaw gateway call system-presence >/tmp/clawx-system-presence.json +``` + +6. Only after `system-presence` succeeds, verify feature-specific RPCs such as Dreams or memory doctor calls. + +## Acceptance Criteria + +- Gateway starts without restart loops. +- `configSyncMs` stays small relative to total startup time. +- `system-presence` succeeds after startup settles. +- Dreams page can refresh once the Gateway process is running and RPC-ready. +- `doctor.memory.status` and `doctor.memory.dreamDiary` return when Dreams is enabled. +- Logs no longer repeat stale runtime cache or escaped managed-skill symlink warnings for entries ClawX can safely clean. + +## Required Regression Coverage + +For fixes in this area, run: + +```bash +pnpm run typecheck +pnpm run lint:check +pnpm exec vitest run tests/unit/openclaw-auth.test.ts tests/unit/skills-symlink-cleanup.test.ts tests/unit/gateway-manager-heartbeat.test.ts tests/unit/gateway-ready-fallback.test.ts +pnpm exec playwright test tests/e2e/openclaw-dreams.spec.ts +pnpm run build:vite +``` + +If the change touches Gateway send/receive, fallback, readiness, or chat history, also run: + +```bash +pnpm run comms:replay +pnpm run comms:compare +``` + +## Reporting Notes + +When sharing findings: + +- Quote log patterns and timing metrics, not full memory doctor output. +- Redact tokens, account identifiers, device IDs, and channel recipients. +- State whether the failure is port readiness, handshake readiness, or RPC readiness. +- Separate ClawX-owned cleanup issues from OpenClaw runtime initialization cost. diff --git a/src/App.tsx b/src/App.tsx index be8fa0c..4468480 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ * Root Application Component * Handles routing and global providers */ -import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'; +import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; import { Component, useEffect } from 'react'; import type { ErrorInfo, ReactNode } from 'react'; import { Toaster } from 'sonner'; @@ -99,6 +99,7 @@ function App() { const theme = useSettingsStore((state) => state.theme); const language = useSettingsStore((state) => state.language); const setupComplete = useSettingsStore((state) => state.setupComplete); + const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const initGateway = useGatewayStore((state) => state.init); const initProviders = useProviderStore((state) => state.init); @@ -192,7 +193,7 @@ function App() { } /> } /> } /> - } /> + : } /> } /> {extraRoutes.map((r) => ( } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e377791..3818df1 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -226,7 +226,9 @@ export function Sidebar() { { to: '/channels', icon: , label: t('sidebar.channels'), testId: 'sidebar-nav-channels' }, { to: '/skills', icon: , label: t('sidebar.skills'), testId: 'sidebar-nav-skills' }, { to: '/cron', icon: , label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' }, - { to: '/dreams', icon: , label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }, + ...(devModeUnlocked + ? [{ to: '/dreams', icon: , label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }] + : []), ]; const navItems = [ diff --git a/src/i18n/locales/en/dreams.json b/src/i18n/locales/en/dreams.json index adf3cac..be309a9 100644 --- a/src/i18n/locales/en/dreams.json +++ b/src/i18n/locales/en/dreams.json @@ -17,11 +17,15 @@ }, "actions": { "title": "Maintenance", + "enable": "Start Dreams", + "disable": "Stop Dreams", "backfill": "Backfill Diary", "dedupe": "Dedupe Diary", "repair": "Repair Cache", "resetGrounded": "Clear Replayed", "resetDiary": "Reset Backfilled Diary", + "enableSuccess": "Dreams started. Gateway may restart to apply the managed schedule.", + "disableSuccess": "Dreams stopped. Gateway may restart to remove the managed schedule.", "backfillSuccess": "Backfilled {{count}} dream diary entries.", "dedupeSuccess": "Removed {{removed}} duplicate dream entries and kept {{kept}}.", "repairSuccess": "Dream cache repair completed.", @@ -62,6 +66,7 @@ "noSnippet": "No snippet available." }, "errors": { - "openFullUi": "Unable to open the full OpenClaw Dreams UI." + "openFullUi": "Unable to open the full OpenClaw Dreams UI.", + "configHashMissing": "Unable to update Dreams because the config base hash is unavailable." } } diff --git a/src/i18n/locales/ja/dreams.json b/src/i18n/locales/ja/dreams.json index 721c113..0c2dddd 100644 --- a/src/i18n/locales/ja/dreams.json +++ b/src/i18n/locales/ja/dreams.json @@ -17,11 +17,15 @@ }, "actions": { "title": "メンテナンス", + "enable": "Dreams を開始", + "disable": "Dreams を停止", "backfill": "日記をバックフィル", "dedupe": "日記を重複排除", "repair": "キャッシュを修復", "resetGrounded": "再生済みを消去", "resetDiary": "バックフィル日記をリセット", + "enableSuccess": "Dreams を開始しました。管理スケジュールを適用するため Gateway が再起動する場合があります。", + "disableSuccess": "Dreams を停止しました。管理スケジュールを削除するため Gateway が再起動する場合があります。", "backfillSuccess": "{{count}} 件の夢日記をバックフィルしました。", "dedupeSuccess": "{{removed}} 件の重複夢エントリを削除し、{{kept}} 件を保持しました。", "repairSuccess": "夢キャッシュの修復が完了しました。", @@ -62,6 +66,7 @@ "noSnippet": "スニペットはありません。" }, "errors": { - "openFullUi": "完全版 OpenClaw Dreams UI を開けません。" + "openFullUi": "完全版 OpenClaw Dreams UI を開けません。", + "configHashMissing": "config base hash が利用できないため Dreams を更新できません。" } } diff --git a/src/i18n/locales/ru/dreams.json b/src/i18n/locales/ru/dreams.json index 2d849b6..3d4ef07 100644 --- a/src/i18n/locales/ru/dreams.json +++ b/src/i18n/locales/ru/dreams.json @@ -17,11 +17,15 @@ }, "actions": { "title": "Обслуживание", + "enable": "Запустить Dreams", + "disable": "Остановить Dreams", "backfill": "Backfill Diary", "dedupe": "Dedupe Diary", "repair": "Repair Cache", "resetGrounded": "Очистить replay", "resetDiary": "Сбросить backfill", + "enableSuccess": "Dreams запущен. Gateway может перезапуститься, чтобы применить управляемое расписание.", + "disableSuccess": "Dreams остановлен. Gateway может перезапуститься, чтобы удалить управляемое расписание.", "backfillSuccess": "Добавлено {{count}} записей дневника снов.", "dedupeSuccess": "Удалено {{removed}} дубликатов записей снов, оставлено {{kept}}.", "repairSuccess": "Восстановление кэша снов завершено.", @@ -62,6 +66,7 @@ "noSnippet": "Фрагмент недоступен." }, "errors": { - "openFullUi": "Не удалось открыть полный OpenClaw Dreams UI." + "openFullUi": "Не удалось открыть полный OpenClaw Dreams UI.", + "configHashMissing": "Не удалось обновить Dreams: недоступен config base hash." } } diff --git a/src/i18n/locales/zh/dreams.json b/src/i18n/locales/zh/dreams.json index 3ca2388..cc1ea2a 100644 --- a/src/i18n/locales/zh/dreams.json +++ b/src/i18n/locales/zh/dreams.json @@ -17,11 +17,15 @@ }, "actions": { "title": "维护", + "enable": "启动梦境", + "disable": "停止梦境", "backfill": "回填日记", "dedupe": "去重日记", "repair": "修复缓存", "resetGrounded": "清除已回放", "resetDiary": "重置回填日记", + "enableSuccess": "梦境已启动。网关可能会重启以应用托管计划。", + "disableSuccess": "梦境已停止。网关可能会重启以移除托管计划。", "backfillSuccess": "已回填 {{count}} 条梦境日记。", "dedupeSuccess": "已移除 {{removed}} 条重复梦境,并保留 {{kept}} 条。", "repairSuccess": "梦境缓存修复完成。", @@ -62,6 +66,7 @@ "noSnippet": "暂无片段。" }, "errors": { - "openFullUi": "无法打开完整 OpenClaw Dreams UI。" + "openFullUi": "无法打开完整 OpenClaw Dreams UI。", + "configHashMissing": "无法更新梦境,因为配置 base hash 不可用。" } } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 9536304..092252c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -894,9 +894,11 @@ function validateGatewayRpcParams(method: string, params: unknown): void { if (!params || typeof params !== 'object' || Array.isArray(params)) { throw new Error('gateway:rpc config.patch requires object params'); } + const raw = (params as Record).raw; + if (typeof raw === 'string' && raw.trim()) return; const patch = (params as Record).patch; if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { - throw new Error('gateway:rpc config.patch requires object patch'); + throw new Error('gateway:rpc config.patch requires raw string or object patch'); } } diff --git a/src/pages/Dreams/index.tsx b/src/pages/Dreams/index.tsx index 2d6c407..16a54ca 100644 --- a/src/pages/Dreams/index.tsx +++ b/src/pages/Dreams/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Archive, BookOpen, @@ -7,6 +7,7 @@ import { ExternalLink, Loader2, Moon, + Power, RefreshCw, RotateCcw, Sparkles, @@ -84,7 +85,16 @@ interface DreamDiaryEntry { summary: string; } +interface ConfigSnapshot { + hash?: string; +} + type DreamActionKey = 'backfill' | 'dedupe' | 'repair' | 'resetDiary' | 'resetGrounded'; +type DreamToggleKey = 'enable' | 'disable'; + +interface RefreshOptions { + force?: boolean; +} interface PendingConfirmation { action: DreamActionKey; @@ -103,6 +113,22 @@ const DREAM_ACTION_METHODS: Record = { const DIARY_START_MARKER = ''; const DIARY_END_MARKER = ''; + +function buildDreamingEnabledPatchRaw(enabled: boolean): string { + return JSON.stringify({ + plugins: { + entries: { + 'memory-core': { + config: { + dreaming: { + enabled, + }, + }, + }, + }, + }, + }); +} const PANEL_CLASS = 'border-black/10 bg-surface-modal shadow-sm dark:border-white/10'; const INSET_CLASS = 'border-black/10 bg-surface-input dark:border-white/10'; const QUIET_BUTTON_CLASS = 'border-black/10 bg-surface-input text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:border-white/10 dark:hover:bg-white/5'; @@ -190,12 +216,15 @@ export function Dreams() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [runningAction, setRunningAction] = useState(null); + const [runningToggle, setRunningToggle] = useState(null); const [lastActionMessage, setLastActionMessage] = useState(null); const [pendingConfirmation, setPendingConfirmation] = useState(null); const [openingFullUi, setOpeningFullUi] = useState(false); + const refreshInFlightRef = useRef | null>(null); - const gatewayReady = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false; - const actionsDisabled = !gatewayReady || runningAction != null; + const gatewayRunning = gatewayStatus.state === 'running'; + const busy = runningAction != null || runningToggle != null; + const actionsDisabled = !gatewayRunning || busy; const diaryEntries = useMemo(() => parseDreamDiary(diary?.content).slice(0, 4), [diary?.content]); const recentSignals = useMemo(() => { @@ -204,29 +233,42 @@ export function Dreams() { return [...shortTerm, ...promoted].slice(0, 6); }, [dreaming?.promotedEntries, dreaming?.shortTermEntries]); - const refreshAll = useCallback(async () => { - if (!gatewayReady) { + const refreshAll = useCallback(async (options?: RefreshOptions) => { + if (refreshInFlightRef.current && !options?.force) { + return refreshInFlightRef.current; + } + + if (!gatewayRunning) { setLoading(false); setError(null); return; } - setLoading(true); - setError(null); - try { - const [statusResponse, diaryResponse] = await Promise.all([ - rpc('doctor.memory.status', {}, 12_000), - rpc('doctor.memory.dreamDiary', {}, 12_000), - ]); - setDreaming(normalizeDreamingStatus(statusResponse)); - setDiary(diaryResponse); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setError(message); - } finally { - setLoading(false); - } - }, [gatewayReady, rpc]); + let refreshPromise!: Promise; + refreshPromise = (async () => { + setLoading(true); + setError(null); + try { + const [statusResponse, diaryResponse] = await Promise.all([ + rpc('doctor.memory.status', {}, 12_000), + rpc('doctor.memory.dreamDiary', {}, 12_000), + ]); + setDreaming(normalizeDreamingStatus(statusResponse)); + setDiary(diaryResponse); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + } finally { + setLoading(false); + if (refreshInFlightRef.current === refreshPromise) { + refreshInFlightRef.current = null; + } + } + })(); + + refreshInFlightRef.current = refreshPromise; + return refreshPromise; + }, [gatewayRunning, rpc]); useEffect(() => { void refreshAll(); @@ -273,6 +315,34 @@ export function Dreams() { } }, [buildActionMessage, refreshAll, rpc]); + const setDreamingEnabled = useCallback(async (enabled: boolean) => { + const toggleKey: DreamToggleKey = enabled ? 'enable' : 'disable'; + setRunningToggle(toggleKey); + setError(null); + setLastActionMessage(null); + try { + const snapshot = await rpc('config.get', {}, 12_000); + if (!snapshot.hash) { + throw new Error(t('errors.configHashMissing')); + } + await rpc('config.patch', { + raw: buildDreamingEnabledPatchRaw(enabled), + baseHash: snapshot.hash, + note: enabled ? 'Enable memory dreaming from ClawX Dreams.' : 'Disable memory dreaming from ClawX Dreams.', + }, 30_000); + const message = enabled ? t('actions.enableSuccess') : t('actions.disableSuccess'); + setDreaming((current) => ({ ...(current ?? {}), enabled })); + setLastActionMessage(message); + toast.success(message); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + toast.error(message); + } finally { + setRunningToggle(null); + } + }, [rpc, t]); + const requestConfirmation = useCallback((action: DreamActionKey) => { setPendingConfirmation({ action, @@ -332,12 +402,23 @@ export function Dreams() {

{t('subtitle')}

+