Skip to content

Commit 68c7af3

Browse files
committed
perf: defer Slack full startup
1 parent 7c4601e commit 68c7af3

16 files changed

Lines changed: 452 additions & 67 deletions

docs/plugins/manifest.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1199,7 +1199,7 @@ Important examples:
11991199
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
12001200
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
12011201
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
1202-
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
1202+
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
12031203

12041204
Manifest metadata decides which provider/channel/setup choices appear in
12051205
onboarding before runtime loads. `package.json#openclaw.install` tells

docs/plugins/sdk-entrypoints.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,22 @@ export default defineBundledChannelSetupEntry({
246246
specifier: "./runtime-api.js",
247247
exportName: "setMyChannelRuntime",
248248
},
249+
registerSetupRuntime(api) {
250+
api.registerHttpRoute({
251+
path: "/my-channel/events",
252+
auth: "plugin",
253+
handler: async (req, res) => {
254+
/* setup-safe route */
255+
},
256+
});
257+
},
249258
});
250259
```
251260

252261
Use that bundled contract only when setup flows truly need a lightweight runtime
253-
setter before the full channel entry loads.
262+
setter or setup-safe gateway surface before the full channel entry loads.
263+
`registerSetupRuntime` runs only for `"setup-runtime"` loads; keep it limited to
264+
config-only routes or methods that must exist before deferred full activation.
254265

255266
## Registration mode
256267

extensions/slack/index.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe("slack bundled entries", () => {
1919
setupEntry,
2020
});
2121

22-
it("registers webhook routes without loading the Slack HTTP route sidecar", async () => {
22+
it("registers webhook routes through the full channel entry", async () => {
2323
const registerHttpRoute = vi.fn();
2424
entry.register({
2525
registrationMode: "tool-discovery",
@@ -70,4 +70,28 @@ describe("slack bundled entries", () => {
7070
"/slack/root",
7171
]);
7272
});
73+
74+
it("registers webhook routes through the setup-runtime entry", () => {
75+
const registerHttpRoute = vi.fn();
76+
setupEntry.registerSetupRuntime?.({
77+
registrationMode: "setup-runtime",
78+
config: {
79+
channels: {
80+
slack: {
81+
webhookPath: "/slack/root",
82+
accounts: {
83+
default: { webhookPath: "/slack/default" },
84+
ops: { webhookPath: "hooks/ops" },
85+
},
86+
},
87+
},
88+
},
89+
registerHttpRoute,
90+
} as never);
91+
92+
expect(registerHttpRoute.mock.calls.map((call) => call[0].path)).toEqual([
93+
"/hooks/ops",
94+
"/slack/default",
95+
]);
96+
});
7397
});

extensions/slack/index.ts

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,5 @@
11
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contract";
3-
4-
const DEFAULT_SLACK_ACCOUNT_ID = "default";
5-
6-
function normalizeSlackWebhookPath(path?: unknown): string {
7-
const trimmed = typeof path === "string" ? path.trim() : "";
8-
if (!trimmed) {
9-
return "/slack/events";
10-
}
11-
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
12-
}
13-
14-
function resolveSlackWebhookPaths(config: OpenClawPluginApi["config"]): string[] {
15-
const slack = config.channels?.slack as
16-
| {
17-
webhookPath?: unknown;
18-
accounts?: Record<string, { webhookPath?: unknown } | undefined>;
19-
}
20-
| undefined;
21-
const accountConfigs = slack?.accounts ?? {};
22-
const paths = new Set<string>();
23-
for (const accountId of new Set([DEFAULT_SLACK_ACCOUNT_ID, ...Object.keys(accountConfigs)])) {
24-
paths.add(
25-
normalizeSlackWebhookPath(accountConfigs[accountId]?.webhookPath ?? slack?.webhookPath),
26-
);
27-
}
28-
return [...paths].toSorted((left, right) => left.localeCompare(right));
29-
}
30-
31-
function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
32-
for (const path of resolveSlackWebhookPaths(api.config)) {
33-
api.registerHttpRoute({
34-
path,
35-
auth: "plugin",
36-
handler: async (req, res) => {
37-
const { handleSlackHttpRequest } = await import("./src/http/registry.js");
38-
return await handleSlackHttpRequest(req, res);
39-
},
40-
});
41-
}
42-
}
2+
import { registerSlackPluginHttpRoutes } from "./http-routes-api.js";
433

444
export default defineBundledChannelEntry({
455
id: "slack",

extensions/slack/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,15 @@
6060
"install": {
6161
"npmSpec": "@openclaw/slack",
6262
"defaultChoice": "npm",
63-
"minHostVersion": ">=2026.5.12-beta.1",
63+
"minHostVersion": ">=2026.5.28",
6464
"allowInvalidConfigRecovery": true
6565
},
6666
"compat": {
6767
"pluginApi": ">=2026.5.28"
6868
},
69+
"startup": {
70+
"deferConfiguredChannelFullLoadUntilAfterListen": true
71+
},
6972
"build": {
7073
"openclawVersion": "2026.5.28",
7174
"bundledDist": false

extensions/slack/setup-entry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
2+
import { registerSlackPluginHttpRoutes } from "./http-routes-api.js";
23

34
export default defineBundledChannelSetupEntry({
45
importMetaUrl: import.meta.url,
@@ -10,4 +11,9 @@ export default defineBundledChannelSetupEntry({
1011
specifier: "./secret-contract-api.js",
1112
exportName: "channelSecrets",
1213
},
14+
runtime: {
15+
specifier: "./runtime-setter-api.js",
16+
exportName: "setSlackRuntime",
17+
},
18+
registerSetupRuntime: registerSlackPluginHttpRoutes,
1319
});

extensions/slack/src/http/plugin-routes.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
22
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
3-
import { listSlackAccountIds, mergeSlackAccountConfig } from "../accounts.js";
43
import { normalizeSlackWebhookPath } from "./paths.js";
54
import { handleSlackHttpRequest } from "./registry.js";
65

7-
export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
8-
const accountIds = new Set<string>([DEFAULT_ACCOUNT_ID, ...listSlackAccountIds(api.config)]);
9-
const registeredPaths = new Set<string>();
10-
for (const accountId of accountIds) {
11-
// Route registration must remain config-only and should not resolve tokens.
12-
const accountConfig = mergeSlackAccountConfig(api.config, accountId);
13-
registeredPaths.add(normalizeSlackWebhookPath(accountConfig.webhookPath));
14-
}
15-
if (registeredPaths.size === 0) {
16-
registeredPaths.add(normalizeSlackWebhookPath());
6+
type SlackWebhookConfig = {
7+
webhookPath?: unknown;
8+
accounts?: Record<string, { webhookPath?: unknown } | undefined>;
9+
};
10+
11+
function resolveSlackWebhookPaths(config: OpenClawPluginApi["config"]): string[] {
12+
const slack = config.channels?.slack as SlackWebhookConfig | undefined;
13+
const accountConfigs = slack?.accounts ?? {};
14+
const paths = new Set<string>();
15+
for (const accountId of new Set([DEFAULT_ACCOUNT_ID, ...Object.keys(accountConfigs)])) {
16+
const path = accountConfigs[accountId]?.webhookPath ?? slack?.webhookPath;
17+
paths.add(normalizeSlackWebhookPath(typeof path === "string" ? path : undefined));
1718
}
18-
for (const path of registeredPaths) {
19+
return [...paths].toSorted((left, right) => left.localeCompare(right));
20+
}
21+
22+
export function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void {
23+
for (const path of resolveSlackWebhookPaths(api.config)) {
1924
api.registerHttpRoute({
2025
path,
2126
auth: "plugin",

src/gateway/server-startup-plugins.test.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ const loadPluginLookUpTable = vi.hoisted(() =>
9191
vi.fn((_params: unknown) => ({
9292
manifestRegistry: pluginManifestRegistry,
9393
startup: {
94-
configuredDeferredChannelPluginIds: [],
95-
pluginIds: ["telegram"],
94+
configuredDeferredChannelPluginIds: [] as string[],
95+
pluginIds: ["telegram"] as string[],
9696
},
9797
metrics: pluginLookUpTableMetrics,
9898
})),
@@ -182,8 +182,8 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
182182
loadPluginLookUpTable.mockClear().mockReturnValue({
183183
manifestRegistry: pluginManifestRegistry,
184184
startup: {
185-
configuredDeferredChannelPluginIds: [],
186-
pluginIds: ["telegram"],
185+
configuredDeferredChannelPluginIds: [] as string[],
186+
pluginIds: ["telegram"] as string[],
187187
},
188188
metrics: pluginLookUpTableMetrics,
189189
});
@@ -306,6 +306,87 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
306306
dreaming: { enabled: false },
307307
});
308308
});
309+
310+
it("loads only deferred setup-runtime plugins during pre-bind bootstrap", async () => {
311+
loadPluginLookUpTable.mockReturnValueOnce({
312+
manifestRegistry: pluginManifestRegistry,
313+
startup: {
314+
configuredDeferredChannelPluginIds: ["slack"] as string[],
315+
pluginIds: ["slack", "memory-core"] as string[],
316+
},
317+
metrics: {
318+
...pluginLookUpTableMetrics,
319+
startupPluginCount: 2,
320+
deferredChannelPluginCount: 1,
321+
},
322+
});
323+
const cfg = {
324+
channels: {
325+
slack: { enabled: true, token: "token" },
326+
},
327+
} as OpenClawConfig;
328+
const log = createLog();
329+
const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js");
330+
331+
const result = await prepareGatewayPluginBootstrap({
332+
cfgAtStart: cfg,
333+
startupRuntimeConfig: cfg,
334+
minimalTestGateway: false,
335+
log,
336+
loadRuntimePlugins: false,
337+
loadSetupRuntimePlugins: true,
338+
});
339+
340+
expect(result.runtimePluginsLoaded).toBe(false);
341+
const startupInput = firstCallArg<{
342+
pluginIds?: string[];
343+
preferSetupRuntimeForChannelPlugins?: boolean;
344+
suppressPluginInfoLogs?: boolean;
345+
}>(loadGatewayStartupPlugins);
346+
expect(startupInput.pluginIds).toEqual(["slack"]);
347+
expect(startupInput.preferSetupRuntimeForChannelPlugins).toBe(true);
348+
expect(startupInput.suppressPluginInfoLogs).toBe(true);
349+
});
350+
351+
it("does not use setup-runtime preference for full bootstrap loads", async () => {
352+
loadPluginLookUpTable.mockReturnValueOnce({
353+
manifestRegistry: pluginManifestRegistry,
354+
startup: {
355+
configuredDeferredChannelPluginIds: ["slack"] as string[],
356+
pluginIds: ["slack", "memory-core"] as string[],
357+
},
358+
metrics: {
359+
...pluginLookUpTableMetrics,
360+
startupPluginCount: 2,
361+
deferredChannelPluginCount: 1,
362+
},
363+
});
364+
const cfg = {
365+
channels: {
366+
slack: { enabled: true, token: "token" },
367+
},
368+
} as OpenClawConfig;
369+
const log = createLog();
370+
const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js");
371+
372+
const result = await prepareGatewayPluginBootstrap({
373+
cfgAtStart: cfg,
374+
startupRuntimeConfig: cfg,
375+
minimalTestGateway: false,
376+
log,
377+
});
378+
379+
expect(result.runtimePluginsLoaded).toBe(true);
380+
const startupInput = firstCallArg<{
381+
pluginIds?: string[];
382+
preferSetupRuntimeForChannelPlugins?: boolean;
383+
suppressPluginInfoLogs?: boolean;
384+
}>(loadGatewayStartupPlugins);
385+
expect(startupInput.pluginIds).toEqual(["slack", "memory-core"]);
386+
expect(startupInput.preferSetupRuntimeForChannelPlugins).toBe(false);
387+
expect(startupInput.suppressPluginInfoLogs).toBe(false);
388+
});
389+
309390
it("bypasses plugin lookup when plugins are globally disabled", async () => {
310391
const cfg = {
311392
channels: {

src/gateway/server-startup-plugins.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export async function prepareGatewayPluginBootstrap(params: {
4343
minimalTestGateway: boolean;
4444
log: GatewayPluginBootstrapLog;
4545
loadRuntimePlugins?: boolean;
46+
loadSetupRuntimePlugins?: boolean;
4647
}) {
4748
const activationSourceConfig = params.activationSourceConfig ?? params.cfgAtStart;
4849
const startupMaintenanceConfig = resolveGatewayStartupMaintenanceConfig({
@@ -114,8 +115,25 @@ export async function prepareGatewayPluginBootstrap(params: {
114115
let pluginRegistry = emptyPluginRegistry;
115116
let baseGatewayMethods = baseMethods;
116117
const shouldLoadRuntimePlugins = params.loadRuntimePlugins !== false;
118+
const shouldLoadSetupRuntimePlugins =
119+
params.loadSetupRuntimePlugins === true && deferredConfiguredChannelPluginIds.length > 0;
117120

118-
if (!params.minimalTestGateway && shouldLoadRuntimePlugins) {
121+
if (!params.minimalTestGateway && shouldLoadSetupRuntimePlugins) {
122+
({ pluginRegistry, gatewayMethods: baseGatewayMethods } = await loadGatewayStartupPluginRuntime(
123+
{
124+
cfg: gatewayPluginConfig,
125+
activationSourceConfig,
126+
workspaceDir: defaultWorkspaceDir,
127+
log: params.log,
128+
baseMethods,
129+
coreGatewayMethodNames,
130+
startupPluginIds: deferredConfiguredChannelPluginIds,
131+
pluginLookUpTable,
132+
preferSetupRuntimeForChannelPlugins: true,
133+
suppressPluginInfoLogs: true,
134+
},
135+
));
136+
} else if (!params.minimalTestGateway && shouldLoadRuntimePlugins) {
119137
({ pluginRegistry, gatewayMethods: baseGatewayMethods } = await loadGatewayStartupPluginRuntime(
120138
{
121139
cfg: gatewayPluginConfig,
@@ -126,8 +144,8 @@ export async function prepareGatewayPluginBootstrap(params: {
126144
coreGatewayMethodNames,
127145
startupPluginIds,
128146
pluginLookUpTable,
129-
preferSetupRuntimeForChannelPlugins: deferredConfiguredChannelPluginIds.length > 0,
130-
suppressPluginInfoLogs: deferredConfiguredChannelPluginIds.length > 0,
147+
preferSetupRuntimeForChannelPlugins: false,
148+
suppressPluginInfoLogs: false,
131149
},
132150
));
133151
} else {
@@ -146,7 +164,8 @@ export async function prepareGatewayPluginBootstrap(params: {
146164
baseMethods,
147165
pluginRegistry,
148166
baseGatewayMethods,
149-
runtimePluginsLoaded: !params.minimalTestGateway && shouldLoadRuntimePlugins,
167+
runtimePluginsLoaded:
168+
!params.minimalTestGateway && shouldLoadRuntimePlugins && !shouldLoadSetupRuntimePlugins,
150169
};
151170
}
152171

src/gateway/server.impl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ export async function startGatewayServer(
676676
minimalTestGateway,
677677
log,
678678
loadRuntimePlugins: false,
679+
loadSetupRuntimePlugins: true,
679680
}),
680681
);
681682
const {

0 commit comments

Comments
 (0)