Skip to content

Commit 8ccb11c

Browse files
authored
perf(plugins,gateway): thread metadata snapshot + discovery through hot paths + plugin owner fixes (#84649)
* perf(plugins): thread metadata snapshot and discovery through hot paths With the snapshot memo now actually hitting, route the snapshot's manifestRegistry and discovery through the helper chains that already had fast paths for them. Eliminates redundant per-call rebuilds at two big amplifiers. - Provider resolve paths (resolvePluginProviders / isPluginProvidersLoadInFlight / resolveOwningPluginIdsForProvider / resolveExternalAuthProfilesWithPlugins) self-service a snapshot once at the public entry, then thread it as a separate required arg through resolvePluginProviderLoadBase, resolveExplicitProviderOwnerPluginIds, and the setup/runtime load state helpers. Inner reads change from 'params.pluginMetadataSnapshot?.x' to 'snapshot.x', no more enrichedParams clone. loadPluginManifestRegistryForInstalledIndex fires drop ~685 -> ~10 per cold start. - Bundled-channel / auto-enable chain accepts an optional PluginDiscoveryResult. discoverOpenClawPlugins is fired once during snapshot building (resolveInstalledPluginIndexRegistry already produced it internally; now bubbled up through loadInstalledPluginIndexWithDiscovery, PluginRegistrySnapshotResult, and onto PluginMetadataSnapshot.discovery). load-context reads metadataSnapshot.discovery and passes it through applyPluginAutoEnable, so the bundled-channel cascade (collectConfiguredChannelIds, listBundledChannelIdsWith*, listPotentialConfiguredChannelPresenceSignals) short-circuits instead of each leaf re-firing discovery. Persisted-cache path is unchanged: no discovery on the snapshot, downstream chain handles its own fallback (pre-PR behavior on that path). * test(plugins): isolate snapshot memo across tests that mock manifest registry The snapshot memo is now process-scoped and effective (~98% hit rate). Three test files were depending on cache misses (because the broken cache returned them) — each test would set up its own loadPluginManifestRegistry mock and expect a fresh derive. With the cache fixed, an earlier test's mocked registry now leaks into later tests in the same file. - io.write-config.test.ts: afterEach now clears the snapshot memo so the 'demo' plugin mocked in the first test does not survive into 'keeps shipped plugin install config records when index migration fails', which expects an empty registry to surface the 'plugin not found: demo' warning. - gateway/model-pricing-cache.ts: resetGatewayModelPricingCacheForTest also clears the memo. Tests in model-pricing-cache.test.ts assert loadPluginManifestRegistryForInstalledIndex was called; the memo hit otherwise skips the call. - providers.test.ts: vi.doMock loadPluginMetadataSnapshot to wrap the existing loadPluginManifestRegistryMock fixture. The plumbing commit added an auto-fetch fall-through in resolveOwningPluginIdsForProvider; without the mock, providers tests hit real disk reads and return empty registries (which is what surfaced as 9 unrelated-looking failures in the prior CI run). * fix(plugins): preserve setup.cliBackends owner matching in provider scan resolveOwningPluginIdsForProvider now also checks plugin.setup?.cliBackends. The pre-PR no-registry fallback used resolvePluginContributionOwners which includes both top-level cliBackends and setup.cliBackends; the PR's manifest scan replacement was missing the setup case. * fix(plugins): inherit active registry workspaceDir before loading metadata snapshot isPluginProvidersLoadInFlight and resolvePluginProviders now resolve env and workspaceDir once at the entry point (falling back to getActivePluginRegistryWorkspaceDir) and pass them into both loadPluginMetadataSnapshot and resolvePluginProviderLoadBase. Pre-fix the snapshot used params.workspaceDir raw while the load base inherited the active workspace, so workspace-scoped provider plugins could be absent from the snapshot manifest registry even though owner resolution expected them. Regression test asserts the snapshot mock receives the active workspaceDir when the caller omits it. * perf(gateway): thread discovery into applyPluginAutoEnable call sites Every gateway applyPluginAutoEnable call now passes the snapshot's PluginDiscoveryResult so the bundled-channel cascade (collectConfiguredChannelIds → listBundledChannelIdsWith* → listPotentialConfiguredChannelPresenceSignals) short-circuits instead of each leaf re-firing discovery. Startup-time sites pull discovery from the snapshot/lookup-table they already hold: - server-plugin-bootstrap.ts (pluginLookUpTable) - server-startup-plugins.ts (pluginMetadataSnapshot) - server-startup-config.ts (pluginMetadataSnapshot) - server-plugins.ts (pluginLookUpTable, both call sites) Per-RPC sites (server.impl getRuntimeConfig callback, server-methods/channels status + start handlers, server-methods/send) source discovery via getCurrentPluginMetadataSnapshot using the runtime config to validate compatibility. Falls through to the original slow path when the snapshot is absent or incompatible.
1 parent 8bf4f7d commit 8ccb11c

27 files changed

Lines changed: 345 additions & 142 deletions

src/channels/config-presence.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { resolveStateDir } from "../config/paths.js";
88
import type { OpenClawConfig } from "../config/types.openclaw.js";
99
import { hasNonEmptyString } from "../infra/outbound/channel-target.js";
10+
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
1011
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
1112
import { isRecord } from "../utils.js";
1213
import { listBundledChannelIds } from "./plugins/bundled-ids.js";
@@ -15,6 +16,7 @@ const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
1516

1617
type ChannelPresenceOptions = {
1718
channelIds?: readonly string[];
19+
discovery?: PluginDiscoveryResult;
1820
includePersistedAuthState?: boolean;
1921
persistedAuthStateProbe?: {
2022
listChannelIds: () => readonly string[];
@@ -71,6 +73,9 @@ function listPersistedAuthStateChannelIds(options: ChannelPresenceOptions): read
7173
if (override) {
7274
return override;
7375
}
76+
if (options.discovery) {
77+
return listBundledChannelIdsWithPersistedAuthState(options.discovery);
78+
}
7479
if (persistedAuthStateChannelIds) {
7580
return persistedAuthStateChannelIds;
7681
}
@@ -88,7 +93,12 @@ function hasPersistedAuthState(params: {
8893
if (override) {
8994
return override.hasState(params);
9095
}
91-
return hasBundledChannelPersistedAuthState(params);
96+
return hasBundledChannelPersistedAuthState({
97+
channelId: params.channelId,
98+
cfg: params.cfg,
99+
env: params.env,
100+
discovery: params.options.discovery,
101+
});
92102
}
93103

94104
export function listPotentialConfiguredChannelIds(
@@ -121,7 +131,7 @@ export function listPotentialConfiguredChannelPresenceSignals(
121131
signals.push({ channelId, source });
122132
};
123133
const configuredChannelIds = new Set<string>();
124-
const channelIds = options.channelIds ?? listBundledChannelIds(env);
134+
const channelIds = options.channelIds ?? listBundledChannelIds(env, options.discovery);
125135
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
126136
const channels = isRecord(cfg.channels) ? cfg.channels : null;
127137
if (channels) {
@@ -165,7 +175,7 @@ function hasEnvConfiguredChannel(
165175
env: NodeJS.ProcessEnv,
166176
options: ChannelPresenceOptions = {},
167177
): boolean {
168-
const channelIds = options.channelIds ?? listBundledChannelIds(env);
178+
const channelIds = options.channelIds ?? listBundledChannelIds(env, options.discovery);
169179
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
170180
for (const [key, value] of Object.entries(env)) {
171181
if (!hasNonEmptyString(value)) {
Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,50 @@
11
import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registry.js";
2+
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
23
import { resolveBundledChannelRootScope } from "./bundled-root.js";
34

45
export function listBundledChannelPluginIdsForRoot(
56
_packageRoot: string,
67
env: NodeJS.ProcessEnv = process.env,
8+
discovery?: PluginDiscoveryResult,
79
): string[] {
8-
return listChannelCatalogEntries({ origin: "bundled", env })
10+
return listChannelCatalogEntries({
11+
origin: "bundled",
12+
env,
13+
discovery,
14+
})
915
.map((entry) => entry.pluginId)
1016
.toSorted((left, right) => left.localeCompare(right));
1117
}
1218

1319
export function listBundledChannelIdsForRoot(
1420
_packageRoot: string,
1521
env: NodeJS.ProcessEnv = process.env,
22+
discovery?: PluginDiscoveryResult,
1623
): string[] {
17-
return listChannelCatalogEntries({ origin: "bundled", env })
24+
return listChannelCatalogEntries({
25+
origin: "bundled",
26+
env,
27+
discovery,
28+
})
1829
.map((entry) => entry.channel.id)
1930
.filter((channelId): channelId is string => Boolean(channelId))
2031
.toSorted((left, right) => left.localeCompare(right));
2132
}
2233

23-
export function listBundledChannelPluginIds(env: NodeJS.ProcessEnv = process.env): string[] {
24-
return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env);
34+
export function listBundledChannelPluginIds(
35+
env: NodeJS.ProcessEnv = process.env,
36+
discovery?: PluginDiscoveryResult,
37+
): string[] {
38+
return listBundledChannelPluginIdsForRoot(
39+
resolveBundledChannelRootScope(env).cacheKey,
40+
env,
41+
discovery,
42+
);
2543
}
2644

27-
export function listBundledChannelIds(env: NodeJS.ProcessEnv = process.env): string[] {
28-
return listBundledChannelIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env);
45+
export function listBundledChannelIds(
46+
env: NodeJS.ProcessEnv = process.env,
47+
discovery?: PluginDiscoveryResult,
48+
): string[] {
49+
return listBundledChannelIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env, discovery);
2950
}
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import type { OpenClawConfig } from "../../config/types.openclaw.js";
2+
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
23
import {
34
hasBundledChannelPackageState,
45
listBundledChannelIdsForPackageState,
56
} from "./package-state-probes.js";
67

7-
export function listBundledChannelIdsWithConfiguredState(): string[] {
8-
return listBundledChannelIdsForPackageState("configuredState");
8+
export function listBundledChannelIdsWithConfiguredState(
9+
discovery?: PluginDiscoveryResult,
10+
): string[] {
11+
return listBundledChannelIdsForPackageState("configuredState", discovery);
912
}
1013

1114
export function hasBundledChannelConfiguredState(params: {
1215
channelId: string;
1316
cfg: OpenClawConfig;
1417
env?: NodeJS.ProcessEnv;
18+
discovery?: PluginDiscoveryResult;
1519
}): boolean {
1620
return hasBundledChannelPackageState({
1721
metadataKey: "configuredState",
1822
channelId: params.channelId,
1923
cfg: params.cfg,
2024
env: params.env,
25+
discovery: params.discovery,
2126
});
2227
}

src/channels/plugins/package-state-probes.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
listChannelCatalogEntries,
99
type PluginChannelCatalogEntry,
1010
} from "../../plugins/channel-catalog-registry.js";
11+
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
1112
import {
1213
getCachedPluginModuleLoader,
1314
type PluginModuleLoaderCache,
@@ -174,10 +175,12 @@ function resolveChannelPackageStateMetadata(
174175

175176
function listChannelPackageStateCatalog(
176177
metadataKey: ChannelPackageStateMetadataKey,
178+
discovery?: PluginDiscoveryResult,
177179
): PluginChannelCatalogEntry[] {
178-
return listChannelCatalogEntries({ origin: "bundled" }).filter((entry) =>
179-
Boolean(resolveChannelPackageStateMetadata(entry, metadataKey)),
180-
);
180+
return listChannelCatalogEntries({
181+
origin: "bundled",
182+
discovery,
183+
}).filter((entry) => Boolean(resolveChannelPackageStateMetadata(entry, metadataKey)));
181184
}
182185

183186
function resolveChannelPackageStateChecker(params: {
@@ -235,8 +238,9 @@ function resolvePackageStateChannelId(entry: PluginChannelCatalogEntry): string
235238

236239
export function listBundledChannelIdsForPackageState(
237240
metadataKey: ChannelPackageStateMetadataKey,
241+
discovery?: PluginDiscoveryResult,
238242
): string[] {
239-
return listChannelPackageStateCatalog(metadataKey)
243+
return listChannelPackageStateCatalog(metadataKey, discovery)
240244
.map((entry) => resolvePackageStateChannelId(entry))
241245
.filter((channelId): channelId is string => Boolean(channelId))
242246
.toSorted((left, right) => left.localeCompare(right));
@@ -247,9 +251,10 @@ export function hasBundledChannelPackageState(params: {
247251
channelId: string;
248252
cfg: OpenClawConfig;
249253
env?: NodeJS.ProcessEnv;
254+
discovery?: PluginDiscoveryResult;
250255
}): boolean {
251256
const requestedChannelId = normalizeOptionalString(params.channelId);
252-
const entry = listChannelPackageStateCatalog(params.metadataKey).find(
257+
const entry = listChannelPackageStateCatalog(params.metadataKey, params.discovery).find(
253258
(candidate) => resolvePackageStateChannelId(candidate) === requestedChannelId,
254259
);
255260
if (!entry) {
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
import type { OpenClawConfig } from "../../config/types.openclaw.js";
2+
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
23
import {
34
hasBundledChannelPackageState,
45
listBundledChannelIdsForPackageState,
56
} from "./package-state-probes.js";
67

7-
export function listBundledChannelIdsWithPersistedAuthState(): string[] {
8-
return listBundledChannelIdsForPackageState("persistedAuthState");
8+
export function listBundledChannelIdsWithPersistedAuthState(
9+
discovery?: PluginDiscoveryResult,
10+
): string[] {
11+
return listBundledChannelIdsForPackageState("persistedAuthState", discovery);
912
}
1013

1114
export function hasBundledChannelPersistedAuthState(params: {
1215
channelId: string;
1316
cfg: OpenClawConfig;
1417
env?: NodeJS.ProcessEnv;
18+
discovery?: PluginDiscoveryResult;
1519
}): boolean {
1620
return hasBundledChannelPackageState({
1721
metadataKey: "persistedAuthState",
1822
channelId: params.channelId,
1923
cfg: params.cfg,
2024
env: params.env,
25+
discovery: params.discovery,
2126
});
2227
}

src/config/plugin-auto-enable.apply.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
12
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
23
import { detectPluginAutoEnableCandidates } from "./plugin-auto-enable.detect.js";
34
import {
@@ -44,6 +45,7 @@ export function applyPluginAutoEnable(params: {
4445
config?: OpenClawConfig;
4546
env?: NodeJS.ProcessEnv;
4647
manifestRegistry?: PluginManifestRegistry;
48+
discovery?: PluginDiscoveryResult;
4749
}): PluginAutoEnableResult {
4850
const candidates = detectPluginAutoEnableCandidates(params);
4951
return materializePluginAutoEnableCandidates({

src/config/plugin-auto-enable.detect.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
12
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
23
import {
34
resolveConfiguredPluginAutoEnableCandidates,
@@ -11,10 +12,11 @@ export function detectPluginAutoEnableCandidates(params: {
1112
config?: OpenClawConfig;
1213
env?: NodeJS.ProcessEnv;
1314
manifestRegistry?: PluginManifestRegistry;
15+
discovery?: PluginDiscoveryResult;
1416
}): PluginAutoEnableCandidate[] {
1517
const env = params.env ?? process.env;
1618
const config = params.config ?? ({} as OpenClawConfig);
17-
const readiness = resolvePluginAutoEnableReadiness(config, env);
19+
const readiness = resolvePluginAutoEnableReadiness(config, env, params.discovery);
1820
if (!readiness.mayNeedAutoEnable) {
1921
return [];
2022
}

src/config/plugin-auto-enable.shared.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
1212
import { normalizePluginsConfig } from "../plugins/config-state.js";
1313
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
14+
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
1415
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
1516
import {
1617
type PluginManifestRecord,
@@ -265,10 +266,15 @@ function collectPluginIdsForConfiguredChannel(
265266
return [claims[0]?.plugin.id ?? builtInId ?? normalizedChannelId];
266267
}
267268

268-
function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
269-
const configuredStateChannelIds = new Set(listBundledChannelIdsWithConfiguredState());
269+
function collectConfiguredChannelIds(
270+
cfg: OpenClawConfig,
271+
env: NodeJS.ProcessEnv,
272+
discovery?: PluginDiscoveryResult,
273+
): string[] {
274+
const configuredStateChannelIds = new Set(listBundledChannelIdsWithConfiguredState(discovery));
270275
return listPotentialConfiguredChannelPresenceSignals(cfg, env, {
271276
includePersistedAuthState: false,
277+
discovery,
272278
})
273279
.map((signal) => ({
274280
source: signal.source,
@@ -281,6 +287,7 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv
281287
channelId,
282288
source,
283289
configuredStateChannelIds,
290+
discovery,
284291
}),
285292
)
286293
.map(({ channelId }) => channelId);
@@ -292,6 +299,7 @@ function isAutoEnableConfiguredChannelSignal(params: {
292299
channelId: string;
293300
source: ChannelPresenceSignalSource;
294301
configuredStateChannelIds: ReadonlySet<string>;
302+
discovery?: PluginDiscoveryResult;
295303
}): boolean {
296304
if (
297305
params.source === "env" &&
@@ -300,6 +308,7 @@ function isAutoEnableConfiguredChannelSignal(params: {
300308
channelId: params.channelId,
301309
cfg: params.cfg,
302310
env: params.env,
311+
discovery: params.discovery,
303312
})
304313
) {
305314
return false;
@@ -535,6 +544,7 @@ export function configMayNeedPluginAutoEnable(
535544
export function resolvePluginAutoEnableReadiness(
536545
cfg: OpenClawConfig,
537546
env: NodeJS.ProcessEnv,
547+
discovery?: PluginDiscoveryResult,
538548
): { mayNeedAutoEnable: boolean; configuredChannelIds: string[] } {
539549
if (arePluginsGloballyDisabled(cfg)) {
540550
return { mayNeedAutoEnable: false, configuredChannelIds: [] };
@@ -545,7 +555,7 @@ export function resolvePluginAutoEnableReadiness(
545555
if (hasConfiguredPluginConfigEntry(cfg)) {
546556
return { mayNeedAutoEnable: true, configuredChannelIds: [] };
547557
}
548-
const configuredChannelIds = collectConfiguredChannelIds(cfg, env);
558+
const configuredChannelIds = collectConfiguredChannelIds(cfg, env, discovery);
549559
if (configuredChannelIds.length > 0) {
550560
return { mayNeedAutoEnable: true, configuredChannelIds };
551561
}

src/gateway/model-pricing-cache.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ import type {
1919
PluginManifestModelPricingProvider,
2020
PluginManifestModelPricingSource,
2121
} from "../plugins/manifest.js";
22-
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
22+
import {
23+
clearLoadPluginMetadataSnapshotMemo,
24+
resolvePluginMetadataSnapshot,
25+
} from "../plugins/plugin-metadata-snapshot.js";
2326
import type { PluginMetadataRegistryView } from "../plugins/plugin-metadata-snapshot.types.js";
2427
import type { PluginRegistrySnapshot } from "../plugins/plugin-registry.js";
2528
import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js";
@@ -1396,6 +1399,7 @@ export function startGatewayModelPricingRefresh(
13961399

13971400
export function resetGatewayModelPricingCacheForTest(): void {
13981401
clearGatewayModelPricingCacheState();
1402+
clearLoadPluginMetadataSnapshotMemo();
13991403
clearRefreshTimer();
14001404
inFlightRefresh = null;
14011405
}

src/gateway/server-methods/channels.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { readConfigFileSnapshot } from "../../config/config.js";
1313
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
1414
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1515
import { getChannelActivity } from "../../infra/channel-activity.js";
16+
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
1617
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
1718
import { defaultRuntime } from "../../runtime.js";
1819
import { normalizeOptionalString } from "../../shared/string-coerce.js";
@@ -301,9 +302,16 @@ export const channelsHandlers: GatewayRequestHandlers = {
301302
const rawChannel = (params as { channel?: unknown }).channel;
302303
const requestedChannel =
303304
typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : undefined;
305+
const runtimeConfig = context.getRuntimeConfig();
306+
const currentSnapshot = getCurrentPluginMetadataSnapshot({
307+
config: runtimeConfig,
308+
env: process.env,
309+
});
304310
const cfg = applyPluginAutoEnable({
305-
config: context.getRuntimeConfig(),
311+
config: runtimeConfig,
306312
env: process.env,
313+
manifestRegistry: currentSnapshot?.manifestRegistry,
314+
discovery: currentSnapshot?.discovery,
307315
}).config;
308316
const runtime = context.getRuntimeSnapshot();
309317
const plugins = listChannelPlugins();
@@ -579,9 +587,16 @@ export const channelsHandlers: GatewayRequestHandlers = {
579587
return;
580588
}
581589
try {
590+
const runtimeConfig = context.getRuntimeConfig();
591+
const currentSnapshot = getCurrentPluginMetadataSnapshot({
592+
config: runtimeConfig,
593+
env: process.env,
594+
});
582595
const cfg = applyPluginAutoEnable({
583-
config: context.getRuntimeConfig(),
596+
config: runtimeConfig,
584597
env: process.env,
598+
manifestRegistry: currentSnapshot?.manifestRegistry,
599+
discovery: currentSnapshot?.discovery,
585600
}).config;
586601
const payload = await startChannelAccount({
587602
channelId,

0 commit comments

Comments
 (0)