From db06fcd990ff9f9996f296fd426ded486a53765e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 10:15:16 +0100 Subject: [PATCH] refactor: unify lazy module loaders --- src/commands/agents.bind.test-support.ts | 8 ++- src/commands/agents.bindings.ts | 2 +- src/commands/agents.commands.bind.ts | 8 ++- src/commands/backup.ts | 8 ++- src/commands/channel-setup/discovery.ts | 2 +- src/commands/channels/add.ts | 15 +++-- src/commands/channels/logs.ts | 2 +- src/commands/configure.wizard.ts | 8 ++- .../shared/legacy-web-search-migrate.ts | 4 +- .../missing-configured-plugin-install.ts | 4 +- .../shared/plugin-tool-allowlist-warnings.ts | 4 +- .../doctor/shared/preview-warnings.ts | 8 ++- .../doctor/shared/stale-plugin-config.ts | 4 +- src/commands/gateway-status.ts | 16 +++-- src/commands/health.ts | 8 ++- src/commands/models/list.list-command.ts | 22 ++++--- src/commands/models/list.manifest-catalog.ts | 4 +- src/commands/models/list.probe.ts | 8 ++- src/commands/models/list.rows.ts | 22 ++++--- src/commands/models/list.status-command.ts | 29 +++++---- src/commands/sessions.ts | 6 +- src/commands/setup.ts | 22 ++++--- src/commands/status-runtime-shared.ts | 18 +++--- src/commands/status.command.ts | 43 ++++++------- src/commands/status.scan-memory.ts | 10 +-- src/commands/status.scan-overview.ts | 62 +++++++++---------- src/commands/status.scan.shared.ts | 16 +++-- src/commands/status.summary.ts | 22 +++---- src/media/image-ops.ts | 43 ++++++------- src/media/mime.ts | 6 +- src/media/qr-runtime.ts | 10 +-- src/plugins/install.ts | 6 +- src/plugins/manifest-channel-contributions.ts | 26 -------- src/plugins/manifest-contribution-ids.ts | 54 ++++++++++++++++ src/plugins/provider-auth-ref.ts | 6 +- src/plugins/provider-discovery.ts | 24 +++---- src/plugins/provider-runtime.runtime.ts | 9 ++- src/shared/lazy-promise.test.ts | 59 ++++++++++++++++++ src/shared/lazy-promise.ts | 44 +++++++++++++ 39 files changed, 408 insertions(+), 264 deletions(-) delete mode 100644 src/plugins/manifest-channel-contributions.ts create mode 100644 src/plugins/manifest-contribution-ids.ts create mode 100644 src/shared/lazy-promise.test.ts create mode 100644 src/shared/lazy-promise.ts diff --git a/src/commands/agents.bind.test-support.ts b/src/commands/agents.bind.test-support.ts index 9916ae6707a..039f8a8f899 100644 --- a/src/commands/agents.bind.test-support.ts +++ b/src/commands/agents.bind.test-support.ts @@ -1,6 +1,7 @@ import type { Mock } from "vitest"; import { vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { createTestRuntime } from "./test-runtime-config-helpers.js"; type ReplaceConfigFileResult = Awaited< @@ -44,11 +45,12 @@ vi.mock("./agents.command-shared.js", () => ({ export const runtime = createTestRuntime(); -let agentsBindCommandModulePromise: Promise | undefined; +const agentsBindCommandModuleLoader = createLazyImportLoader( + () => import("./agents.commands.bind.js"), +); export async function loadFreshAgentsBindCommandModuleForTest() { - agentsBindCommandModulePromise ??= import("./agents.commands.bind.js"); - return await agentsBindCommandModulePromise; + return await agentsBindCommandModuleLoader.load(); } export function resetAgentsBindTestHarness(): void { diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index 25b873bcf19..283670c521d 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -6,7 +6,7 @@ import { normalizeChannelId as normalizeBundledChannelId } from "../channels/reg import { isRouteBinding, listRouteBindings } from "../config/bindings.js"; import type { AgentRouteBinding } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { listManifestChannelContributionIds } from "../plugins/manifest-channel-contributions.js"; +import { listManifestChannelContributionIds } from "../plugins/manifest-contribution-ids.js"; import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts index 287bc616911..cc7aaa139a9 100644 --- a/src/commands/agents.commands.bind.ts +++ b/src/commands/agents.commands.bind.ts @@ -6,6 +6,7 @@ import type { AgentRouteBinding } from "../config/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { describeBinding } from "./agents.binding-format.js"; import { requireValidConfig, requireValidConfigFileSnapshot } from "./agents.command-shared.js"; @@ -29,11 +30,12 @@ type AgentsUnbindOptions = { json?: boolean; }; -let agentBindingsModulePromise: Promise | undefined; +const agentBindingsModuleLoader = createLazyImportLoader( + () => import("./agents.bindings.js"), +); function loadAgentBindingsModule(): Promise { - agentBindingsModulePromise ??= import("./agents.bindings.js"); - return agentBindingsModulePromise; + return agentBindingsModuleLoader.load(); } function resolveAgentId( diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 7f4a800c4bf..1b3362a1676 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -5,15 +5,17 @@ import { type BackupCreateResult, } from "../infra/backup-create.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; export type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.js"; type BackupVerifyRuntime = typeof import("./backup-verify.js"); -let backupVerifyRuntimePromise: Promise | undefined; +const backupVerifyRuntimeLoader = createLazyImportLoader( + () => import("./backup-verify.js"), +); function loadBackupVerifyRuntime(): Promise { - backupVerifyRuntimePromise ??= import("./backup-verify.js"); - return backupVerifyRuntimePromise; + return backupVerifyRuntimeLoader.load(); } export async function backupCreateCommand( diff --git a/src/commands/channel-setup/discovery.ts b/src/commands/channel-setup/discovery.ts index deb3e91e9b2..162bd02471a 100644 --- a/src/commands/channel-setup/discovery.ts +++ b/src/commands/channel-setup/discovery.ts @@ -8,7 +8,7 @@ import type { ChannelMeta } from "../../channels/plugins/types.public.js"; import { isStaticallyChannelConfigured } from "../../config/channel-configured-shared.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { listManifestChannelContributionIds } from "../../plugins/manifest-channel-contributions.js"; +import { listManifestChannelContributionIds } from "../../plugins/manifest-contribution-ids.js"; import type { ChannelChoice } from "../onboard-types.js"; import { listSetupDiscoveryChannelPluginCatalogEntries, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index def686a0005..0e93bfdb457 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -11,6 +11,7 @@ import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-regi import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; @@ -22,17 +23,19 @@ import { requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; type ChannelSetupPluginInstallModule = typeof import("../channel-setup/plugin-install.js"); type OnboardChannelsModule = typeof import("../onboard-channels.js"); -let channelSetupPluginInstallPromise: Promise | undefined; -let onboardChannelsPromise: Promise | undefined; +const channelSetupPluginInstallLoader = createLazyImportLoader( + () => import("../channel-setup/plugin-install.js"), +); +const onboardChannelsLoader = createLazyImportLoader( + () => import("../onboard-channels.js"), +); function loadChannelSetupPluginInstall(): Promise { - channelSetupPluginInstallPromise ??= import("../channel-setup/plugin-install.js"); - return channelSetupPluginInstallPromise; + return channelSetupPluginInstallLoader.load(); } function loadOnboardChannels(): Promise { - onboardChannelsPromise ??= import("../onboard-channels.js"); - return onboardChannelsPromise; + return onboardChannelsLoader.load(); } export type ChannelsAddOptions = { diff --git a/src/commands/channels/logs.ts b/src/commands/channels/logs.ts index fb776622ad6..cbb9f68a629 100644 --- a/src/commands/channels/logs.ts +++ b/src/commands/channels/logs.ts @@ -3,7 +3,7 @@ import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/ import { getResolvedLoggerSettings } from "../../logging.js"; import { resolveLogFile } from "../../logging/log-tail.js"; import { parseLogLine } from "../../logging/parse-log-line.js"; -import { listManifestChannelContributionIds } from "../../plugins/manifest-channel-contributions.js"; +import { listManifestChannelContributionIds } from "../../plugins/manifest-contribution-ids.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { theme } from "../../terminal/theme.js"; diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index aa2f421be1a..43b41a88fc8 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -12,6 +12,7 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; import { isPlainObject, resolveUserPath } from "../utils.js"; @@ -56,11 +57,12 @@ type SetupPluginConfigModule = typeof import("../wizard/setup.plugin-config.js") const GATEWAY_HINT_PROBE_TIMEOUT_MS = 300; -let setupPluginConfigModulePromise: Promise | undefined; +const setupPluginConfigModuleLoader = createLazyImportLoader( + () => import("../wizard/setup.plugin-config.js"), +); function loadSetupPluginConfigModule(): Promise { - setupPluginConfigModulePromise ??= import("../wizard/setup.plugin-config.js"); - return setupPluginConfigModulePromise; + return setupPluginConfigModuleLoader.load(); } function mergeWizardConfigOntoLatest(current: unknown, base: unknown, next: unknown): unknown { diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts index 45e2ab42fa7..6de4fddd7df 100644 --- a/src/commands/doctor/shared/legacy-web-search-migrate.ts +++ b/src/commands/doctor/shared/legacy-web-search-migrate.ts @@ -1,5 +1,5 @@ import { mergeMissing } from "../../../config/legacy.shared.js"; -import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js"; +import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; import { cloneRecord, ensureRecord, @@ -17,7 +17,7 @@ const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave"; function getBundledLegacyWebSearchOwners(): ReadonlyMap { const owners = new Map(); - for (const plugin of loadPluginMetadataSnapshot({ config: {}, env: process.env }).plugins) { + for (const plugin of loadManifestMetadataSnapshot({ config: {}, env: process.env }).plugins) { if (plugin.origin !== "bundled") { continue; } diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index ff75945f4cd..3048b76d671 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -6,7 +6,7 @@ import { installPluginFromNpmSpec } from "../../../plugins/install.js"; import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js"; -import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js"; +import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { updateNpmInstalledPlugins } from "../../../plugins/update.js"; import { asObjectRecord } from "./object.js"; @@ -154,7 +154,7 @@ export async function repairMissingConfiguredPluginInstalls(params: { }): Promise<{ changes: string[]; warnings: string[] }> { const env = params.env ?? process.env; const knownIds = new Set( - loadPluginMetadataSnapshot({ + loadManifestMetadataSnapshot({ config: params.cfg, env, }).plugins.map((plugin) => plugin.id), diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 07263fd3781..500cd9cabdb 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -1,8 +1,8 @@ import { normalizeToolName } from "../../../agents/tool-policy-shared.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizePluginId } from "../../../plugins/config-state.js"; +import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js"; -import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js"; type ToolAllowlistSource = { label: string; @@ -147,7 +147,7 @@ export function collectPluginToolAllowlistWarnings(params: { const registry = params.manifestRegistry ?? - loadPluginMetadataSnapshot({ + loadManifestMetadataSnapshot({ config: params.cfg, env: params.env ?? process.env, }).manifestRegistry; diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 7eed7fddf50..ddd4c2accc4 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -3,14 +3,16 @@ import { isToolAllowedByPolicies } from "../../../agents/tool-policy-match.js"; import { mergeAlsoAllowPolicy, resolveToolProfilePolicy } from "../../../agents/tool-policy.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { AgentToolsConfig, ToolsConfig } from "../../../config/types.tools.js"; +import { createLazyImportLoader } from "../../../shared/lazy-promise.js"; type ChannelDoctorModule = typeof import("./channel-doctor.js"); -let channelDoctorModulePromise: Promise | undefined; +const channelDoctorModuleLoader = createLazyImportLoader( + () => import("./channel-doctor.js"), +); function loadChannelDoctorModule(): Promise { - channelDoctorModulePromise ??= import("./channel-doctor.js"); - return channelDoctorModulePromise; + return channelDoctorModuleLoader.load(); } function hasRecord(value: unknown): value is Record { diff --git a/src/commands/doctor/shared/stale-plugin-config.ts b/src/commands/doctor/shared/stale-plugin-config.ts index 77be5b0c127..775f84bf327 100644 --- a/src/commands/doctor/shared/stale-plugin-config.ts +++ b/src/commands/doctor/shared/stale-plugin-config.ts @@ -3,7 +3,7 @@ import { CHANNEL_IDS } from "../../../channels/ids.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizePluginId } from "../../../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "../../../plugins/installed-plugin-index-records.js"; -import { loadPluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.js"; +import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import { asObjectRecord } from "./object.js"; @@ -29,7 +29,7 @@ function collectPluginRegistryState( env?: NodeJS.ProcessEnv, ): StalePluginRegistryState { const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); - const registry = loadPluginMetadataSnapshot({ + const registry = loadManifestMetadataSnapshot({ config: cfg, workspaceDir: workspaceDir ?? undefined, env: env ?? process.env, diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index d1d77d8a07f..1f6d8a79e83 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -2,6 +2,7 @@ import { withProgress } from "../cli/progress.js"; import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import type { RuntimeEnv } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { isRich } from "../terminal/theme.js"; import { inferSshTargetFromRemoteUrl, resolveSshTarget } from "./gateway-status/discovery.js"; import { @@ -18,23 +19,20 @@ import { } from "./gateway-status/output.js"; import { runGatewayStatusProbePass } from "./gateway-status/probe-run.js"; -let sshConfigModulePromise: Promise | undefined; -let sshTunnelModulePromise: Promise | undefined; -let gatewayTlsModulePromise: Promise | undefined; +const sshConfigModuleLoader = createLazyImportLoader(() => import("../infra/ssh-config.js")); +const sshTunnelModuleLoader = createLazyImportLoader(() => import("../infra/ssh-tunnel.js")); +const gatewayTlsModuleLoader = createLazyImportLoader(() => import("../infra/tls/gateway.js")); function loadSshConfigModule() { - sshConfigModulePromise ??= import("../infra/ssh-config.js"); - return sshConfigModulePromise; + return sshConfigModuleLoader.load(); } function loadSshTunnelModule() { - sshTunnelModulePromise ??= import("../infra/ssh-tunnel.js"); - return sshTunnelModulePromise; + return sshTunnelModuleLoader.load(); } function loadGatewayTlsModule() { - gatewayTlsModulePromise ??= import("../infra/tls/gateway.js"); - return gatewayTlsModulePromise; + return gatewayTlsModuleLoader.load(); } export async function gatewayStatusCommand( diff --git a/src/commands/health.ts b/src/commands/health.ts index 93930b4e3e9..cc747716241 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -23,6 +23,7 @@ import { getActivePluginRegistry } from "../plugins/runtime.js"; import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { styleHealthChannelLine } from "../terminal/health-style.js"; import { isRich } from "../terminal/theme.js"; @@ -48,11 +49,12 @@ const DEFAULT_TIMEOUT_MS = 10_000; type ConfigModule = typeof import("../config/config.js"); -let configModulePromise: Promise | undefined; +const configModuleLoader = createLazyImportLoader( + () => import("../config/config.js"), +); function loadConfigModule(): Promise { - configModulePromise ??= import("../config/config.js"); - return configModulePromise; + return configModuleLoader.load(); } const debugHealth = (...args: unknown[]) => { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 60727791bae..4318e1b4c3b 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -2,6 +2,7 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import { parseModelRef } from "../../agents/model-selection.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { createModelListAuthIndex } from "./list.auth-index.js"; import { resolveConfiguredEntries } from "./list.configured.js"; @@ -17,23 +18,26 @@ type RegistryLoadModule = typeof import("./list.registry-load.js"); type RowSourcesModule = typeof import("./list.row-sources.js"); type SourcePlanModule = typeof import("./list.source-plan.js"); -let registryLoadModulePromise: Promise | undefined; -let rowSourcesModulePromise: Promise | undefined; -let sourcePlanModulePromise: Promise | undefined; +const registryLoadModuleLoader = createLazyImportLoader( + () => import("./list.registry-load.js"), +); +const rowSourcesModuleLoader = createLazyImportLoader( + () => import("./list.row-sources.js"), +); +const sourcePlanModuleLoader = createLazyImportLoader( + () => import("./list.source-plan.js"), +); function loadRegistryLoadModule(): Promise { - registryLoadModulePromise ??= import("./list.registry-load.js"); - return registryLoadModulePromise; + return registryLoadModuleLoader.load(); } function loadRowSourcesModule(): Promise { - rowSourcesModulePromise ??= import("./list.row-sources.js"); - return rowSourcesModulePromise; + return rowSourcesModuleLoader.load(); } function loadSourcePlanModule(): Promise { - sourcePlanModulePromise ??= import("./list.source-plan.js"); - return sourcePlanModulePromise; + return sourcePlanModuleLoader.load(); } export async function modelsListCommand( diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts index 92a11144d44..21f0b4f6033 100644 --- a/src/commands/models/list.manifest-catalog.ts +++ b/src/commands/models/list.manifest-catalog.ts @@ -4,8 +4,8 @@ import { planManifestModelCatalogRows, } from "../../model-catalog/index.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; +import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js"; -import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { getPluginRecord, isPluginEnabled, @@ -98,7 +98,7 @@ function loadManifestCatalogRowsForList(params: { ? normalizeModelCatalogProviderId(params.providerFilter) : undefined; const mode = params.mode ?? "static-authoritative"; - const snapshot = loadPluginMetadataSnapshot({ + const snapshot = loadManifestMetadataSnapshot({ config: params.cfg, env: params.env ?? process.env, }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 1bc2a965465..761b294fa24 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -28,16 +28,18 @@ import { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { coerceSecretRef, normalizeSecretInputString } from "../../config/types.secrets.js"; import { type SecretRefResolveCache, resolveSecretRefString } from "../../secrets/resolve.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { redactSecrets } from "../status-all/format.js"; import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; const PROBE_PROMPT = "Reply with OK. Do not use tools."; -let embeddedRunnerModulePromise: Promise | undefined; +const embeddedRunnerModuleLoader = createLazyImportLoader( + () => import("../../agents/pi-embedded.js"), +); function loadEmbeddedRunnerModule() { - embeddedRunnerModulePromise ??= import("../../agents/pi-embedded.js"); - return embeddedRunnerModulePromise; + return embeddedRunnerModuleLoader.load(); } export type AuthProbeStatus = diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 84902065315..d4239a8d210 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -9,6 +9,7 @@ import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { ModelListAuthIndex } from "./list.auth-index.js"; import type { ListRowModel } from "./list.model-row.js"; import { toModelRow } from "./list.model-row.js"; @@ -36,23 +37,26 @@ export type RowBuilderContext = { skipRuntimeModelSuppression?: boolean; }; -let modelCatalogModulePromise: Promise | undefined; -let modelResolverModulePromise: Promise | undefined; -let providerCatalogModulePromise: Promise | undefined; +const modelCatalogModuleLoader = createLazyImportLoader( + () => import("../../agents/model-catalog.js"), +); +const modelResolverModuleLoader = createLazyImportLoader( + () => import("../../agents/pi-embedded-runner/model.js"), +); +const providerCatalogModuleLoader = createLazyImportLoader( + () => import("./list.provider-catalog.js"), +); function loadModelCatalogModule(): Promise { - modelCatalogModulePromise ??= import("../../agents/model-catalog.js"); - return modelCatalogModulePromise; + return modelCatalogModuleLoader.load(); } function loadModelResolverModule(): Promise { - modelResolverModulePromise ??= import("../../agents/pi-embedded-runner/model.js"); - return modelResolverModulePromise; + return modelResolverModuleLoader.load(); } function loadProviderCatalogModule(): Promise { - providerCatalogModulePromise ??= import("./list.provider-catalog.js"); - return providerCatalogModulePromise; + return providerCatalogModuleLoader.load(); } function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 805bf95e91f..c55d615e386 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -42,6 +42,7 @@ import type { ProviderSyntheticAuthResult } from "../../plugins/provider-externa import { resolveProviderSyntheticAuthWithPlugin } from "../../plugins/provider-runtime.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; +import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; @@ -61,10 +62,18 @@ type ProgressRuntime = typeof import("../../cli/progress.js"); type TerminalTableRuntime = typeof import("../../terminal/table.js"); type ListProbeRuntime = typeof import("./list.probe.js"); -let providerUsageRuntimePromise: Promise | undefined; -let progressRuntimePromise: Promise | undefined; -let terminalTableRuntimePromise: Promise | undefined; -let listProbeRuntimePromise: Promise | undefined; +const providerUsageRuntimeLoader = createLazyImportLoader( + () => import("../../infra/provider-usage.js"), +); +const progressRuntimeLoader = createLazyImportLoader( + () => import("../../cli/progress.js"), +); +const terminalTableRuntimeLoader = createLazyImportLoader( + () => import("../../terminal/table.js"), +); +const listProbeRuntimeLoader = createLazyImportLoader( + () => import("./list.probe.js"), +); const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; @@ -77,23 +86,19 @@ type StatusSyntheticAuth = { }; function loadProviderUsageRuntime(): Promise { - providerUsageRuntimePromise ??= import("../../infra/provider-usage.js"); - return providerUsageRuntimePromise; + return providerUsageRuntimeLoader.load(); } function loadProgressRuntime(): Promise { - progressRuntimePromise ??= import("../../cli/progress.js"); - return progressRuntimePromise; + return progressRuntimeLoader.load(); } function loadTerminalTableRuntime(): Promise { - terminalTableRuntimePromise ??= import("../../terminal/table.js"); - return terminalTableRuntimePromise; + return terminalTableRuntimeLoader.load(); } function loadListProbeRuntime(): Promise { - listProbeRuntimePromise ??= import("./list.probe.js"); - return listProbeRuntimePromise; + return listProbeRuntimeLoader.load(); } function resolveProviderConfigForStatus( diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index c2b9cdb87d7..db4c00c73c4 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -5,6 +5,7 @@ import { loadSessionStore, resolveSessionTotalTokens } from "../config/sessions. import { info } from "../globals.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { isRich, theme } from "../terminal/theme.js"; import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js"; import { @@ -33,7 +34,7 @@ type SessionRow = SessionDisplayRow & { const AGENT_PAD = 10; const KIND_PAD = 6; const TOKENS_PAD = 20; -let contextLookupRuntimePromise: Promise | null = null; +const contextLookupRuntimeLoader = createLazyImportLoader(() => import("../agents/context.js")); const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; @@ -72,8 +73,7 @@ const formatTokensCell = ( }; async function lookupContextTokensForDisplay(model: string): Promise { - contextLookupRuntimePromise ??= import("../agents/context.js"); - const { lookupContextTokens } = await contextLookupRuntimePromise; + const { lookupContextTokens } = await contextLookupRuntimeLoader.load(); return lookupContextTokens(model, { allowAsyncLoad: false }); } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index e12dcd1a6ec..6e113165692 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -5,6 +5,7 @@ import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.j import type { OpenClawConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { shortenHomePath } from "../utils.js"; import { safeParseWithSchema } from "../utils/zod-parse.js"; @@ -41,23 +42,26 @@ type AgentWorkspaceModule = typeof import("../agents/workspace.js"); type ConfigIOModule = typeof import("../config/config.js"); type ConfigLoggingModule = typeof import("../config/logging.js"); -let agentWorkspaceModulePromise: Promise | undefined; -let configIOModulePromise: Promise | undefined; -let configLoggingModulePromise: Promise | undefined; +const agentWorkspaceModuleLoader = createLazyImportLoader( + () => import("../agents/workspace.js"), +); +const configIOModuleLoader = createLazyImportLoader( + () => import("../config/config.js"), +); +const configLoggingModuleLoader = createLazyImportLoader( + () => import("../config/logging.js"), +); function loadAgentWorkspaceModule(): Promise { - agentWorkspaceModulePromise ??= import("../agents/workspace.js"); - return agentWorkspaceModulePromise; + return agentWorkspaceModuleLoader.load(); } function loadConfigIOModule(): Promise { - configIOModulePromise ??= import("../config/config.js"); - return configIOModulePromise; + return configIOModuleLoader.load(); } function loadConfigLoggingModule(): Promise { - configLoggingModulePromise ??= import("../config/logging.js"); - return configLoggingModulePromise; + return configLoggingModuleLoader.load(); } async function createDefaultConfigIO(): Promise { diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 8a44502c6ef..93fc05deaa2 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -1,26 +1,26 @@ import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { OpenClawConfig } from "../config/types.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import type { HealthSummary } from "./health.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; -let providerUsagePromise: Promise | undefined; -let securityAuditModulePromise: Promise | undefined; -let gatewayCallModulePromise: Promise | undefined; +const providerUsageLoader = createLazyImportLoader(() => import("../infra/provider-usage.js")); +const securityAuditModuleLoader = createLazyImportLoader( + () => import("../security/audit.runtime.js"), +); +const gatewayCallModuleLoader = createLazyImportLoader(() => import("../gateway/call.js")); function loadProviderUsage() { - providerUsagePromise ??= import("../infra/provider-usage.js"); - return providerUsagePromise; + return providerUsageLoader.load(); } function loadSecurityAuditModule() { - securityAuditModulePromise ??= import("../security/audit.runtime.js"); - return securityAuditModulePromise; + return securityAuditModuleLoader.load(); } function loadGatewayCallModule() { - gatewayCallModulePromise ??= import("../gateway/call.js"); - return gatewayCallModulePromise; + return gatewayCallModuleLoader.load(); } export async function resolveStatusSecurityAudit(params: { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 83507b932dd..45ca82d85d8 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -6,6 +6,7 @@ import { type ConnectPairingRequiredReason, } from "../gateway/protocol/connect-error-details.js"; import { type RuntimeEnv } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { runStatusJsonCommand } from "./status-json-command.ts"; import { buildStatusOverviewSurfaceFromScan } from "./status-overview-surface.ts"; @@ -20,47 +21,41 @@ import { buildStatusCommandReportData } from "./status.command-report-data.ts"; import { buildStatusCommandReportLines } from "./status.command-report.ts"; import { logGatewayConnectionDetails } from "./status.gateway-connection.ts"; -let statusScanModulePromise: Promise | undefined; -let statusScanFastJsonModulePromise: - | Promise - | undefined; -let statusAllModulePromise: Promise | undefined; -let statusCommandTextRuntimePromise: - | Promise - | undefined; -let statusGatewayConnectionRuntimePromise: - | Promise - | undefined; -let statusNodeModeModulePromise: Promise | undefined; +const statusScanModuleLoader = createLazyImportLoader(() => import("./status.scan.js")); +const statusScanFastJsonModuleLoader = createLazyImportLoader( + () => import("./status.scan.fast-json.js"), +); +const statusAllModuleLoader = createLazyImportLoader(() => import("./status-all.js")); +const statusCommandTextRuntimeLoader = createLazyImportLoader( + () => import("./status.command.text-runtime.js"), +); +const statusGatewayConnectionRuntimeLoader = createLazyImportLoader( + () => import("./status.gateway-connection.runtime.js"), +); +const statusNodeModeModuleLoader = createLazyImportLoader(() => import("./status.node-mode.js")); function loadStatusScanModule() { - statusScanModulePromise ??= import("./status.scan.js"); - return statusScanModulePromise; + return statusScanModuleLoader.load(); } function loadStatusScanFastJsonModule() { - statusScanFastJsonModulePromise ??= import("./status.scan.fast-json.js"); - return statusScanFastJsonModulePromise; + return statusScanFastJsonModuleLoader.load(); } function loadStatusAllModule() { - statusAllModulePromise ??= import("./status-all.js"); - return statusAllModulePromise; + return statusAllModuleLoader.load(); } function loadStatusCommandTextRuntime() { - statusCommandTextRuntimePromise ??= import("./status.command.text-runtime.js"); - return statusCommandTextRuntimePromise; + return statusCommandTextRuntimeLoader.load(); } function loadStatusGatewayConnectionRuntime() { - statusGatewayConnectionRuntimePromise ??= import("./status.gateway-connection.runtime.js"); - return statusGatewayConnectionRuntimePromise; + return statusGatewayConnectionRuntimeLoader.load(); } function loadStatusNodeModeModule() { - statusNodeModeModulePromise ??= import("./status.node-mode.js"); - return statusNodeModeModulePromise; + return statusNodeModeModuleLoader.load(); } export function resolvePairingRecoveryContext(params: { diff --git a/src/commands/status.scan-memory.ts b/src/commands/status.scan-memory.ts index fd00d035213..cb758b7ff01 100644 --- a/src/commands/status.scan-memory.ts +++ b/src/commands/status.scan-memory.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; import { resolveSharedMemoryStatusSnapshot, @@ -10,13 +11,12 @@ import { type MemoryStatusSnapshot, } from "./status.scan.shared.js"; -let statusScanDepsRuntimeModulePromise: - | Promise - | undefined; +const statusScanDepsRuntimeModuleLoader = createLazyImportLoader( + () => import("./status.scan.deps.runtime.js"), +); function loadStatusScanDepsRuntimeModule() { - statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); - return statusScanDepsRuntimeModulePromise; + return statusScanDepsRuntimeModuleLoader.load(); } export function resolveDefaultMemoryStorePath(agentId: string): string { diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index 422e927830e..e84ef4883e6 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -4,6 +4,7 @@ import { resolveOsSummary } from "../infra/os-summary.js"; import type { UpdateCheckResult } from "../infra/update-check.js"; import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import type { RuntimeEnv } from "../runtime.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js"; import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js"; import { @@ -13,65 +14,60 @@ import { import { loadStatusScanCommandConfig } from "./status.scan.config-shared.js"; import type { GatewayProbeSnapshot } from "./status.scan.shared.js"; -let statusScanDepsRuntimeModulePromise: - | Promise - | undefined; -let statusAgentLocalModulePromise: Promise | undefined; -let statusUpdateModulePromise: Promise | undefined; -let statusScanRuntimeModulePromise: Promise | undefined; -let gatewayCallModulePromise: Promise | undefined; -let statusSummaryModulePromise: Promise | undefined; -let configModulePromise: Promise | undefined; -let commandConfigResolutionModulePromise: - | Promise - | undefined; -let commandSecretTargetsModulePromise: - | Promise - | undefined; +const statusScanDepsRuntimeModuleLoader = createLazyImportLoader( + () => import("./status.scan.deps.runtime.js"), +); +const statusAgentLocalModuleLoader = createLazyImportLoader( + () => import("./status.agent-local.js"), +); +const statusUpdateModuleLoader = createLazyImportLoader(() => import("./status.update.js")); +const statusScanRuntimeModuleLoader = createLazyImportLoader( + () => import("./status.scan.runtime.js"), +); +const gatewayCallModuleLoader = createLazyImportLoader(() => import("../gateway/call.js")); +const statusSummaryModuleLoader = createLazyImportLoader(() => import("./status.summary.js")); +const configModuleLoader = createLazyImportLoader(() => import("../config/config.js")); +const commandConfigResolutionModuleLoader = createLazyImportLoader( + () => import("../cli/command-config-resolution.js"), +); +const commandSecretTargetsModuleLoader = createLazyImportLoader( + () => import("../cli/command-secret-targets.js"), +); function loadStatusScanDepsRuntimeModule() { - statusScanDepsRuntimeModulePromise ??= import("./status.scan.deps.runtime.js"); - return statusScanDepsRuntimeModulePromise; + return statusScanDepsRuntimeModuleLoader.load(); } function loadStatusAgentLocalModule() { - statusAgentLocalModulePromise ??= import("./status.agent-local.js"); - return statusAgentLocalModulePromise; + return statusAgentLocalModuleLoader.load(); } function loadStatusUpdateModule() { - statusUpdateModulePromise ??= import("./status.update.js"); - return statusUpdateModulePromise; + return statusUpdateModuleLoader.load(); } function loadStatusScanRuntimeModule() { - statusScanRuntimeModulePromise ??= import("./status.scan.runtime.js"); - return statusScanRuntimeModulePromise; + return statusScanRuntimeModuleLoader.load(); } function loadGatewayCallModule() { - gatewayCallModulePromise ??= import("../gateway/call.js"); - return gatewayCallModulePromise; + return gatewayCallModuleLoader.load(); } function loadStatusSummaryModule() { - statusSummaryModulePromise ??= import("./status.summary.js"); - return statusSummaryModulePromise; + return statusSummaryModuleLoader.load(); } function loadConfigModule() { - configModulePromise ??= import("../config/config.js"); - return configModulePromise; + return configModuleLoader.load(); } function loadCommandConfigResolutionModule() { - commandConfigResolutionModulePromise ??= import("../cli/command-config-resolution.js"); - return commandConfigResolutionModulePromise; + return commandConfigResolutionModuleLoader.load(); } function loadCommandSecretTargetsModule() { - commandSecretTargetsModulePromise ??= import("../cli/command-secret-targets.js"); - return commandSecretTargetsModulePromise; + return commandSecretTargetsModuleLoader.load(); } async function resolveStatusChannelsStatus(params: { diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index 9846a202b42..c179dbe2d30 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -7,6 +7,7 @@ import type { GatewayProbeResult, probeGateway as probeGatewayFn } from "../gate import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { isLoopbackIpAddress } from "../shared/net/ip.js"; import { normalizeOptionalLowercaseString, @@ -16,23 +17,20 @@ import { pickGatewaySelfPresence } from "./gateway-presence.js"; import { isProbeReachable } from "./gateway-status/helpers.js"; export { pickGatewaySelfPresence } from "./gateway-presence.js"; -let gatewayProbeModulePromise: Promise | undefined; -let probeGatewayModulePromise: Promise | undefined; -let gatewayCallModulePromise: Promise | undefined; +const gatewayProbeModuleLoader = createLazyImportLoader(() => import("./status.gateway-probe.js")); +const probeGatewayModuleLoader = createLazyImportLoader(() => import("../gateway/probe.js")); +const gatewayCallModuleLoader = createLazyImportLoader(() => import("../gateway/call.js")); function loadGatewayProbeModule() { - gatewayProbeModulePromise ??= import("./status.gateway-probe.js"); - return gatewayProbeModulePromise; + return gatewayProbeModuleLoader.load(); } function loadProbeGatewayModule() { - probeGatewayModulePromise ??= import("../gateway/probe.js"); - return probeGatewayModulePromise; + return probeGatewayModuleLoader.load(); } function loadGatewayCallModule() { - gatewayCallModulePromise ??= import("../gateway/call.js"); - return gatewayCallModulePromise; + return gatewayCallModuleLoader.load(); } export type MemoryStatusSnapshot = MemoryProviderStatus & { diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 89ac19f1321..64e93ab59ca 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -11,24 +11,25 @@ import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js"; import { peekSystemEvents } from "../infra/system-events.js"; import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.types.js"; -let channelSummaryModulePromise: Promise | undefined; -let linkChannelModulePromise: Promise | undefined; -let taskRegistryMaintenanceModulePromise: - | Promise - | undefined; +const channelSummaryModuleLoader = createLazyImportLoader( + () => import("../infra/channel-summary.js"), +); +const linkChannelModuleLoader = createLazyImportLoader(() => import("./status.link-channel.js")); +const taskRegistryMaintenanceModuleLoader = createLazyImportLoader( + () => import("../tasks/task-registry.maintenance.js"), +); function loadChannelSummaryModule() { - channelSummaryModulePromise ??= import("../infra/channel-summary.js"); - return channelSummaryModulePromise; + return channelSummaryModuleLoader.load(); } function loadLinkChannelModule() { - linkChannelModulePromise ??= import("./status.link-channel.js"); - return linkChannelModulePromise; + return linkChannelModuleLoader.load(); } const loadStatusSummaryRuntimeModule = createLazyRuntimeSurface( @@ -37,8 +38,7 @@ const loadStatusSummaryRuntimeModule = createLazyRuntimeSurface( ); function loadTaskRegistryMaintenanceModule() { - taskRegistryMaintenanceModulePromise ??= import("../tasks/task-registry.maintenance.js"); - return taskRegistryMaintenanceModulePromise; + return taskRegistryMaintenanceModuleLoader.load(); } const buildFlags = (entry?: SessionEntry): string[] => { diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index 630cb56e9fa..dc53c719085 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runExec } from "../process/exec.js"; +import { createLazyPromiseLoader } from "../shared/lazy-promise.js"; export type ImageMetadata = { width: number; @@ -54,8 +55,6 @@ function prefersSips(): boolean { ); } -let mediaAttachmentImageOpsPromise: Promise | null = null; - function isMediaAttachmentImageOps(value: unknown): value is MediaAttachmentImageOps { if (!value || typeof value !== "object") { return false; @@ -71,30 +70,24 @@ function isMediaAttachmentImageOps(value: unknown): value is MediaAttachmentImag ); } -async function loadMediaAttachmentImageOps(): Promise { - if (!mediaAttachmentImageOpsPromise) { - mediaAttachmentImageOpsPromise = Promise.resolve() - .then(async () => { - const { loadBundledPluginPublicArtifactModuleSync } = - await import("../plugins/public-surface-loader.js"); - const mod = loadBundledPluginPublicArtifactModuleSync({ - dirName: MEDIA_UNDERSTANDING_CORE_PLUGIN_ID, - artifactBasename: MEDIA_UNDERSTANDING_CORE_IMAGE_OPS_ARTIFACT, - }); - const ops = mod.createMediaAttachmentImageOps?.({ - maxInputPixels: MAX_IMAGE_INPUT_PIXELS, - }); - if (!isMediaAttachmentImageOps(ops)) { - throw new Error("Media understanding core did not expose image ops"); - } - return ops; - }) - .catch((err) => { - mediaAttachmentImageOpsPromise = null; - throw err; - }); +const mediaAttachmentImageOpsLoader = createLazyPromiseLoader(async () => { + const { loadBundledPluginPublicArtifactModuleSync } = + await import("../plugins/public-surface-loader.js"); + const mod = loadBundledPluginPublicArtifactModuleSync({ + dirName: MEDIA_UNDERSTANDING_CORE_PLUGIN_ID, + artifactBasename: MEDIA_UNDERSTANDING_CORE_IMAGE_OPS_ARTIFACT, + }); + const ops = mod.createMediaAttachmentImageOps?.({ + maxInputPixels: MAX_IMAGE_INPUT_PIXELS, + }); + if (!isMediaAttachmentImageOps(ops)) { + throw new Error("Media understanding core did not expose image ops"); } - return await mediaAttachmentImageOpsPromise; + return ops; +}); + +async function loadMediaAttachmentImageOps(): Promise { + return await mediaAttachmentImageOpsLoader.load(); } function isPositiveImageDimension(value: number): boolean { diff --git a/src/media/mime.ts b/src/media/mime.ts index 861a582607d..9b1a8da7d13 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { type MediaKind, mediaKindFromMime } from "./constants.js"; /** @internal */ @@ -66,7 +67,7 @@ const AUDIO_FILE_EXTENSIONS = new Set([ ".wav", ]); -let fileTypeModulePromise: Promise | undefined; +const fileTypeModuleLoader = createLazyImportLoader(() => import("file-type")); export function normalizeMimeType(mime?: string | null): string | undefined { if (!mime) { @@ -89,8 +90,7 @@ async function sniffMime(buffer?: Buffer): Promise { return undefined; } try { - fileTypeModulePromise ??= import("file-type"); - const { fileTypeFromBuffer } = await fileTypeModulePromise; + const { fileTypeFromBuffer } = await fileTypeModuleLoader.load(); const type = await fileTypeFromBuffer(sliceMimeSniffBuffer(buffer)); if (type?.mime) { return type.mime; diff --git a/src/media/qr-runtime.ts b/src/media/qr-runtime.ts index c3e2874fb8f..eed786b96a8 100644 --- a/src/media/qr-runtime.ts +++ b/src/media/qr-runtime.ts @@ -1,14 +1,14 @@ import type QRCode from "qrcode"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; type QrCodeRuntime = typeof QRCode; -let qrCodeRuntimePromise: Promise | null = null; +const qrCodeRuntimeLoader = createLazyImportLoader(() => + import("qrcode").then((mod) => mod.default ?? mod), +); export async function loadQrCodeRuntime(): Promise { - if (!qrCodeRuntimePromise) { - qrCodeRuntimePromise = import("qrcode").then((mod) => mod.default ?? mod); - } - return await qrCodeRuntimePromise; + return await qrCodeRuntimeLoader.load(); } export function normalizeQrText(text: string): string { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 165d62d6e59..8e8d17dab78 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -17,6 +17,7 @@ import { createSafeNpmInstallEnv, } from "../infra/safe-package-install.js"; import { runCommandWithTimeout } from "../process/exec.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; import { @@ -38,11 +39,10 @@ import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; export { resolvePluginInstallDir } from "./install-paths.js"; -let pluginInstallRuntimePromise: Promise | undefined; +const pluginInstallRuntimeLoader = createLazyImportLoader(() => import("./install.runtime.js")); async function loadPluginInstallRuntime() { - pluginInstallRuntimePromise ??= import("./install.runtime.js"); - return pluginInstallRuntimePromise; + return await pluginInstallRuntimeLoader.load(); } type PluginInstallLogger = { diff --git a/src/plugins/manifest-channel-contributions.ts b/src/plugins/manifest-channel-contributions.ts deleted file mode 100644 index fe1c4e4d6ff..00000000000 --- a/src/plugins/manifest-channel-contributions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { listPluginContributionIds, loadPluginRegistrySnapshot } from "./plugin-registry.js"; - -export function listManifestChannelContributionIds( - params: { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - includeDisabled?: boolean; - } = {}, -): readonly string[] { - const env = params.env ?? process.env; - const index = loadPluginRegistrySnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - env, - }); - return listPluginContributionIds({ - index, - contribution: "channels", - config: params.config, - workspaceDir: params.workspaceDir, - env, - includeDisabled: params.includeDisabled, - }); -} diff --git a/src/plugins/manifest-contribution-ids.ts b/src/plugins/manifest-contribution-ids.ts new file mode 100644 index 00000000000..8a0019d1590 --- /dev/null +++ b/src/plugins/manifest-contribution-ids.ts @@ -0,0 +1,54 @@ +import { + listPluginContributionIds, + loadPluginRegistrySnapshot, + type LoadPluginRegistryParams, + type PluginRegistryContributionKey, + type PluginRegistrySnapshot, +} from "./plugin-registry.js"; + +export type ListManifestContributionIdsParams = LoadPluginRegistryParams & { + contribution: PluginRegistryContributionKey; + index?: PluginRegistrySnapshot; + includeDisabled?: boolean; +}; + +export function listManifestContributionIds( + params: ListManifestContributionIdsParams, +): readonly string[] { + const env = params.env ?? process.env; + const index = + params.index ?? + loadPluginRegistrySnapshot({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + candidates: params.candidates, + preferPersisted: params.preferPersisted, + }); + return listPluginContributionIds({ + index, + contribution: params.contribution, + config: params.config, + workspaceDir: params.workspaceDir, + env, + includeDisabled: params.includeDisabled, + }); +} + +export function listManifestChannelContributionIds( + params: Omit = {}, +): readonly string[] { + return listManifestContributionIds({ + ...params, + contribution: "channels", + }); +} + +export function listManifestProviderContributionIds( + params: Omit = {}, +): readonly string[] { + return listManifestContributionIds({ + ...params, + contribution: "providers", + }); +} diff --git a/src/plugins/provider-auth-ref.ts b/src/plugins/provider-auth-ref.ts index 5f98a3b3aed..5d86b5b9a3e 100644 --- a/src/plugins/provider-auth-ref.ts +++ b/src/plugins/provider-auth-ref.ts @@ -9,17 +9,17 @@ import { isValidFileSecretRefId, resolveDefaultSecretProviderAlias, } from "../secrets/ref-contract.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; import type { WizardPrompter } from "../wizard/prompts.js"; -let secretResolvePromise: Promise | undefined; +const secretResolveLoader = createLazyImportLoader(() => import("../secrets/resolve.js")); function loadSecretResolve() { - secretResolvePromise ??= import("../secrets/resolve.js"); - return secretResolvePromise; + return secretResolveLoader.load(); } const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 5e3f9eea30a..3b0723cda8b 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -1,22 +1,20 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { listManifestProviderContributionIds } from "./manifest-contribution-ids.js"; import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js"; -import { - listPluginContributionIds, - loadPluginRegistrySnapshot, - type LoadPluginRegistryParams, - type PluginRegistrySnapshot, -} from "./plugin-registry.js"; +import { type LoadPluginRegistryParams, type PluginRegistrySnapshot } from "./plugin-registry.js"; import type { ProviderDiscoveryOrder, ProviderPlugin } from "./types.js"; const DISCOVERY_ORDER: readonly ProviderDiscoveryOrder[] = ["simple", "profile", "paired", "late"]; const DANGEROUS_PROVIDER_KEYS = new Set(["__proto__", "prototype", "constructor"]); -let providerRuntimePromise: Promise | undefined; +const providerRuntimeLoader = createLazyImportLoader( + () => import("./provider-discovery.runtime.js"), +); function loadProviderRuntime() { - providerRuntimePromise ??= import("./provider-discovery.runtime.js"); - return providerRuntimePromise; + return providerRuntimeLoader.load(); } function resolveProviderCatalogHook(provider: ProviderPlugin) { @@ -62,13 +60,11 @@ export function resolveInstalledPluginProviderContributionIds( params.candidates && params.preferPersisted === undefined ? { ...params, preferPersisted: false } : params; - const index = params.index ?? loadPluginRegistrySnapshot(registryParams); return sortedValues( - listPluginContributionIds({ - index, - contribution: "providers", + listManifestProviderContributionIds({ + ...registryParams, + index: params.index, includeDisabled: params.includeDisabled, - config: params.config, }), ); } diff --git a/src/plugins/provider-runtime.runtime.ts b/src/plugins/provider-runtime.runtime.ts index c8675f38ed1..126cf7e4efa 100644 --- a/src/plugins/provider-runtime.runtime.ts +++ b/src/plugins/provider-runtime.runtime.ts @@ -1,3 +1,5 @@ +import { createLazyImportLoader } from "../shared/lazy-promise.js"; + type ProviderRuntimeModule = typeof import("./provider-runtime.js"); type AugmentModelCatalogWithProviderPlugins = @@ -12,13 +14,14 @@ type PrepareProviderRuntimeAuth = ProviderRuntimeModule["prepareProviderRuntimeA type RefreshProviderOAuthCredentialWithPlugin = ProviderRuntimeModule["refreshProviderOAuthCredentialWithPlugin"]; -let providerRuntimePromise: Promise | undefined; +const providerRuntimeLoader = createLazyImportLoader( + () => import("./provider-runtime.js"), +); async function loadProviderRuntime(): Promise { // Keep the heavy provider runtime behind an actual async boundary so callers // can import this wrapper eagerly without collapsing the lazy chunk. - providerRuntimePromise ??= import("./provider-runtime.js"); - return providerRuntimePromise; + return await providerRuntimeLoader.load(); } export async function augmentModelCatalogWithProviderPlugins( diff --git a/src/shared/lazy-promise.test.ts b/src/shared/lazy-promise.test.ts new file mode 100644 index 00000000000..120ab07dd56 --- /dev/null +++ b/src/shared/lazy-promise.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLazyImportLoader, createLazyPromiseLoader } from "./lazy-promise.js"; + +describe("createLazyPromiseLoader", () => { + it("dedupes concurrent loads and reuses the resolved value", async () => { + let calls = 0; + const loader = createLazyPromiseLoader(async () => `loaded-${++calls}`); + + await expect(Promise.all([loader.load(), loader.load()])).resolves.toEqual([ + "loaded-1", + "loaded-1", + ]); + await expect(loader.load()).resolves.toBe("loaded-1"); + expect(calls).toBe(1); + }); + + it("evicts rejected loads by default so retries can recover", async () => { + let calls = 0; + const loader = createLazyPromiseLoader(async () => { + calls += 1; + if (calls === 1) { + throw new Error("transient"); + } + return "recovered"; + }); + + await expect(loader.load()).rejects.toThrow("transient"); + await expect(loader.load()).resolves.toBe("recovered"); + expect(calls).toBe(2); + }); + + it("can keep rejected loads when requested", async () => { + const load = vi.fn(async () => { + throw new Error("sticky"); + }); + const loader = createLazyPromiseLoader(load, { cacheRejections: true }); + + await expect(loader.load()).rejects.toThrow("sticky"); + await expect(loader.load()).rejects.toThrow("sticky"); + expect(load).toHaveBeenCalledOnce(); + }); + + it("clears cached values", async () => { + let calls = 0; + const loader = createLazyPromiseLoader(() => `loaded-${++calls}`); + + await expect(loader.load()).resolves.toBe("loaded-1"); + loader.clear(); + await expect(loader.load()).resolves.toBe("loaded-2"); + }); +}); + +describe("createLazyImportLoader", () => { + it("wraps import-shaped loaders", async () => { + const loader = createLazyImportLoader(async () => ({ value: "module" })); + + await expect(loader.load()).resolves.toEqual({ value: "module" }); + }); +}); diff --git a/src/shared/lazy-promise.ts b/src/shared/lazy-promise.ts new file mode 100644 index 00000000000..4015e8cf17f --- /dev/null +++ b/src/shared/lazy-promise.ts @@ -0,0 +1,44 @@ +export type LazyPromiseLoader = { + load(): Promise; + clear(): void; +}; + +export type LazyPromiseLoaderOptions = { + cacheRejections?: boolean; +}; + +export function createLazyPromiseLoader( + load: () => T | Promise, + options: LazyPromiseLoaderOptions = {}, +): LazyPromiseLoader { + let promise: Promise | undefined; + + const createPromise = (): Promise => { + const loaded = Promise.resolve().then(load); + if (options.cacheRejections !== true) { + void loaded.catch(() => { + if (promise === loaded) { + promise = undefined; + } + }); + } + return loaded; + }; + + return { + async load(): Promise { + promise ??= createPromise(); + return await promise; + }, + clear(): void { + promise = undefined; + }, + }; +} + +export function createLazyImportLoader( + load: () => Promise, + options?: LazyPromiseLoaderOptions, +): LazyPromiseLoader { + return createLazyPromiseLoader(load, options); +}