mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(hooks): expose typed gateway startup context
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl.
|
||||
- Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc.
|
||||
- Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev.
|
||||
- Plugins/gateway hooks: expose startup config, workspace dir, and a live cron getter on the typed `gateway_start` hook, and move memory-core managed dreaming off the internal `gateway:startup` bridge so cron reconciliation stays on the public plugin hook path. Thanks @vincentkoc.
|
||||
- Gateway/restart: preserve group and channel chat context when resuming an agent turn after a Gateway restart, so continuation replies keep the same prompt, routing, and tool-status behavior as the original conversation.
|
||||
- Gateway/pairing: shared-secret loopback CLI clients now silently auto-approve `metadata-upgrade` pairing (platform / device family refresh) instead of being disconnected with `1008 pairing required`. This matches the scope-upgrade and role-upgrade behavior added in #69431 and unblocks non-interactive CLI automation when a paired-device record has a stale platform string (e.g. device key replicated across hosts, install migrated between OSes, or platform-string format changed between OpenClaw versions). Browser / Control-UI clients keep the existing approval-required flow for metadata changes.
|
||||
- Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ba9b9d9b321b405fef89d4e95c1a3d629d1b956398a5b2a7f25b2a7654879783 plugin-sdk-api-baseline.json
|
||||
8bbbee0ea2326148d4fd49f61fe74f83c5bb24c0742cfbf3609f43939fcd4c34 plugin-sdk-api-baseline.jsonl
|
||||
475eafc1885c69203ac690a0ef3c1d1ddf6879d904a6980cf5f172c29ed70868 plugin-sdk-api-baseline.json
|
||||
906bb0b167b155d2f766bd13f720c8c6ed8f349769d39f57ff5070045b96ef4c plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -462,6 +462,7 @@ AI CLI backend such as `codex-cli`.
|
||||
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
|
||||
- `message_received`: use the typed `threadId` field when you need inbound thread/topic routing. Keep `metadata` for channel-specific extras.
|
||||
- `message_sending`: use typed `replyToId` / `threadId` routing fields before falling back to channel-specific `metadata`.
|
||||
- `gateway_start`: use `ctx.config`, `ctx.workspaceDir`, and `ctx.getCron?.()` for gateway-owned startup state instead of relying on internal `gateway:startup` hooks.
|
||||
|
||||
### API object fields
|
||||
|
||||
|
||||
@@ -3,12 +3,6 @@ import path from "node:path";
|
||||
import { enqueueSystemEvent, resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearInternalHooks,
|
||||
createInternalHookEvent,
|
||||
registerInternalHook,
|
||||
triggerInternalHook,
|
||||
} from "../../../src/hooks/internal-hooks.js";
|
||||
import {
|
||||
__testing,
|
||||
reconcileShortTermDreamingCronJob,
|
||||
@@ -26,6 +20,8 @@ afterEach(() => {
|
||||
resetSystemEventsForTest();
|
||||
});
|
||||
|
||||
function clearInternalHooks(): void {}
|
||||
|
||||
type CronParam = NonNullable<Parameters<typeof reconcileShortTermDreamingCronJob>[0]["cron"]>;
|
||||
type CronJobLike = Awaited<ReturnType<CronParam["list"]>>[number];
|
||||
type CronAddInput = Parameters<CronParam["add"]>[0];
|
||||
@@ -36,7 +32,7 @@ type DreamingPluginApiTestDouble = {
|
||||
pluginConfig: Record<string, unknown>;
|
||||
logger: ReturnType<typeof createLogger>;
|
||||
runtime: unknown;
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => void;
|
||||
registerHook: (event: string, handler: (event: unknown) => unknown) => void;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -156,6 +152,29 @@ function getBeforeAgentReplyHandler(
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function getGatewayStartHandler(
|
||||
onMock: ReturnType<typeof vi.fn>,
|
||||
): (
|
||||
event: { port: number },
|
||||
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
|
||||
) => Promise<unknown> {
|
||||
const call = onMock.mock.calls.find(([eventName]) => eventName === "gateway_start");
|
||||
if (!call) {
|
||||
throw new Error("gateway_start hook was not registered");
|
||||
}
|
||||
return call[1] as (
|
||||
event: { port: number },
|
||||
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
async function triggerGatewayStart(
|
||||
onMock: ReturnType<typeof vi.fn>,
|
||||
ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown },
|
||||
): Promise<void> {
|
||||
await getGatewayStartHandler(onMock)({ port: 18789 }, ctx);
|
||||
}
|
||||
|
||||
function registerShortTermPromotionDreamingForTest(api: DreamingPluginApiTestDouble): void {
|
||||
registerShortTermPromotionDreaming(api as unknown as DreamingPluginApi);
|
||||
}
|
||||
@@ -380,17 +399,11 @@ describe("short-term dreaming config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("short-term dreaming startup event parsing", () => {
|
||||
it("resolves cron service from gateway startup event deps", () => {
|
||||
describe("short-term dreaming gateway_start context parsing", () => {
|
||||
it("resolves cron service from the typed gateway_start cron getter", () => {
|
||||
const harness = createCronHarness();
|
||||
const resolved = __testing.resolveCronServiceFromStartupEvent({
|
||||
type: "gateway",
|
||||
action: "startup",
|
||||
context: {
|
||||
deps: {
|
||||
cron: harness.cron,
|
||||
},
|
||||
},
|
||||
const resolved = __testing.resolveCronServiceFromGatewayContext({
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
expect(resolved).toBe(harness.cron);
|
||||
});
|
||||
@@ -720,40 +733,37 @@ describe("gateway startup reconciliation", () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: { plugins: { entries: {} } },
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
on: vi.fn(),
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: {
|
||||
hooks: { internal: { enabled: true } },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: {
|
||||
hooks: { internal: { enabled: true } },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
expect(harness.addCalls[0]).toMatchObject({
|
||||
@@ -795,21 +805,16 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
const deps = { cron: harness.cron };
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps,
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.addCalls).toHaveLength(0);
|
||||
|
||||
@@ -870,21 +875,17 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
const deps = { cron: startupHarness.cron };
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps,
|
||||
}),
|
||||
);
|
||||
const cronRef = { current: startupHarness.cron };
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => cronRef.current,
|
||||
});
|
||||
|
||||
expect(startupHarness.addCalls).toHaveLength(1);
|
||||
const managed = startupHarness.jobs.find((job) =>
|
||||
@@ -903,7 +904,7 @@ describe("gateway startup reconciliation", () => {
|
||||
]
|
||||
: [],
|
||||
);
|
||||
deps.cron = reloadedHarness.cron;
|
||||
cronRef.current = reloadedHarness.cron;
|
||||
api.config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -962,20 +963,16 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
expect(harness.addCalls).toHaveLength(1);
|
||||
|
||||
harness.jobs.splice(
|
||||
@@ -1028,20 +1025,16 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.listCalls).toBe(1);
|
||||
|
||||
@@ -1084,20 +1077,16 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
expect(harness.listCalls).toBe(1);
|
||||
|
||||
@@ -1140,20 +1129,16 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
const sessionKey = "agent:main:main";
|
||||
enqueueSystemEvent(constants.DREAMING_SYSTEM_EVENT_TEXT, {
|
||||
@@ -1207,20 +1192,16 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: { cron: harness.cron },
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => harness.cron,
|
||||
});
|
||||
|
||||
enqueueSystemEvent(constants.DREAMING_SYSTEM_EVENT_TEXT, {
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -1242,7 +1223,7 @@ describe("gateway startup reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit the cron-unavailable warning on gateway:startup when deps.cron is missing (regression #69939)", async () => {
|
||||
it("does not emit the cron-unavailable warning on gateway_start when cron is missing (regression #69939)", async () => {
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
@@ -1250,43 +1231,38 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
// Simulate the startup race: gateway:startup fires before deps.cron is attached.
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: {
|
||||
hooks: { internal: { enabled: true } },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
await triggerGatewayStart(api.on, {
|
||||
config: {
|
||||
hooks: { internal: { enabled: true } },
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
deps: {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
getCron: () => undefined,
|
||||
});
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("cron service unavailable"),
|
||||
);
|
||||
// The startup-path log should be demoted to debug instead.
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining("cron service not yet available at gateway:startup"),
|
||||
expect.stringContaining("cron service not yet available at gateway_start"),
|
||||
);
|
||||
} finally {
|
||||
clearInternalHooks();
|
||||
@@ -1316,21 +1292,17 @@ describe("gateway startup reconciliation", () => {
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
|
||||
registerInternalHook(event, handler);
|
||||
},
|
||||
registerHook: () => {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
// Startup without cron — must stay silent on warn.
|
||||
await triggerInternalHook(
|
||||
createInternalHookEvent("gateway", "startup", "gateway:startup", {
|
||||
cfg: api.config,
|
||||
deps: {},
|
||||
}),
|
||||
);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => undefined,
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
|
||||
// Now a runtime heartbeat reconciliation happens and cron is still missing
|
||||
|
||||
@@ -21,7 +21,6 @@ import { writeDeepDreamingReport } from "./dreaming-markdown.js";
|
||||
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
|
||||
import { runDreamingSweepPhases } from "./dreaming-phases.js";
|
||||
import {
|
||||
asRecord,
|
||||
formatErrorMessage,
|
||||
includesSystemEventToken,
|
||||
normalizeTrimmedString,
|
||||
@@ -95,11 +94,6 @@ type CronServiceLike = {
|
||||
remove: (id: string) => Promise<{ removed?: boolean }>;
|
||||
};
|
||||
|
||||
type StartupCronSourceRefs = {
|
||||
context: Record<string, unknown>;
|
||||
deps: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type ShortTermPromotionDreamingConfig = {
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
@@ -311,45 +305,8 @@ function resolveCronServiceFromCandidate(candidate: unknown): CronServiceLike |
|
||||
return cron as CronServiceLike;
|
||||
}
|
||||
|
||||
function resolveStartupCronSourceFromEvent(event: unknown): StartupCronSourceRefs | null {
|
||||
const payload = asRecord(event);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
if (payload.type !== "gateway" || payload.action !== "startup") {
|
||||
return null;
|
||||
}
|
||||
const context = asRecord(payload.context);
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
return { context, deps: asRecord(context.deps) };
|
||||
}
|
||||
|
||||
function resolveCronServiceFromStartupSource(
|
||||
source: StartupCronSourceRefs | null,
|
||||
): CronServiceLike | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
resolveCronServiceFromCandidate(source.context.cron) ??
|
||||
resolveCronServiceFromCandidate(source.deps?.cron)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
|
||||
return resolveCronServiceFromStartupSource(resolveStartupCronSourceFromEvent(event));
|
||||
}
|
||||
|
||||
function resolveStartupConfigFromEvent(event: unknown, fallback: OpenClawConfig): OpenClawConfig {
|
||||
const startupEvent = asRecord(event);
|
||||
const startupContext = asRecord(startupEvent?.context);
|
||||
const startupCfg = asRecord(startupContext?.cfg);
|
||||
if (!startupCfg) {
|
||||
return fallback;
|
||||
}
|
||||
return startupCfg as OpenClawConfig;
|
||||
function resolveCronServiceFromGatewayContext(context: { getCron?: () => unknown } | undefined) {
|
||||
return resolveCronServiceFromCandidate(context?.getCron?.());
|
||||
}
|
||||
|
||||
function resolveDreamingTriggerSessionKeys(sessionKey?: string): string[] {
|
||||
@@ -675,7 +632,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
}
|
||||
|
||||
export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void {
|
||||
let startupCronSource: StartupCronSourceRefs | null = null;
|
||||
let resolveStartupCron: (() => CronServiceLike | null) | null = null;
|
||||
let unavailableCronWarningEmitted = false;
|
||||
let lastRuntimeReconcileAtMs = 0;
|
||||
let lastRuntimeConfigKey: string | null = null;
|
||||
@@ -699,12 +656,11 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
|
||||
const reconcileManagedDreamingCron = async (params: {
|
||||
reason: "startup" | "runtime";
|
||||
startupEvent?: unknown;
|
||||
startupConfig?: OpenClawConfig;
|
||||
startupCron?: (() => CronServiceLike | null) | null;
|
||||
}): Promise<ShortTermPromotionDreamingConfig> => {
|
||||
const startupCfg =
|
||||
params.reason === "startup" && params.startupEvent !== undefined
|
||||
? resolveStartupConfigFromEvent(params.startupEvent, api.config)
|
||||
: api.config;
|
||||
params.reason === "startup" ? (params.startupConfig ?? api.config) : api.config;
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig:
|
||||
resolveMemoryCorePluginConfig(startupCfg) ??
|
||||
@@ -712,20 +668,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
api.pluginConfig,
|
||||
cfg: startupCfg,
|
||||
});
|
||||
if (params.reason === "startup" && params.startupEvent !== undefined) {
|
||||
startupCronSource = resolveStartupCronSourceFromEvent(params.startupEvent);
|
||||
if (params.reason === "startup") {
|
||||
resolveStartupCron = params.startupCron ?? null;
|
||||
}
|
||||
const cron = resolveCronServiceFromStartupSource(startupCronSource);
|
||||
const cron = resolveStartupCron?.() ?? null;
|
||||
const configKey = runtimeConfigKey(config);
|
||||
if (!cron && config.enabled && !unavailableCronWarningEmitted) {
|
||||
// The gateway emits `gateway:startup` via a deferred setTimeout, and
|
||||
// `deps.cron` may not be attached to the event context yet when memory-core's
|
||||
// startup hook fires (see issue #69939). Avoid logging a confusing warning on
|
||||
// the startup path — the runtime reconciliation path (heartbeat-driven) will
|
||||
// still warn if the cron service remains unavailable after boot.
|
||||
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
|
||||
// The runtime reconciliation path (heartbeat-driven) will still warn if the
|
||||
// cron service remains unavailable after boot.
|
||||
if (params.reason === "startup") {
|
||||
api.logger.debug?.(
|
||||
"memory-core: cron service not yet available at gateway:startup; deferring to runtime reconciliation.",
|
||||
"memory-core: cron service not yet available at gateway_start; deferring to runtime reconciliation.",
|
||||
);
|
||||
} else {
|
||||
api.logger.warn(
|
||||
@@ -760,22 +714,19 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
return config;
|
||||
};
|
||||
|
||||
api.registerHook(
|
||||
"gateway:startup",
|
||||
async (event: unknown) => {
|
||||
try {
|
||||
await reconcileManagedDreamingCron({
|
||||
reason: "startup",
|
||||
startupEvent: event,
|
||||
});
|
||||
} catch (err) {
|
||||
api.logger.error(
|
||||
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
{ name: "memory-core-short-term-dreaming-cron" },
|
||||
);
|
||||
api.on("gateway_start", async (_event, ctx) => {
|
||||
try {
|
||||
await reconcileManagedDreamingCron({
|
||||
reason: "startup",
|
||||
startupConfig: ctx.config,
|
||||
startupCron: () => resolveCronServiceFromGatewayContext(ctx),
|
||||
});
|
||||
} catch (err) {
|
||||
api.logger.error(
|
||||
`memory-core: dreaming startup reconciliation failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
api.on("before_agent_reply", async (event, ctx) => {
|
||||
try {
|
||||
@@ -811,7 +762,7 @@ export const __testing = {
|
||||
buildManagedDreamingCronJob,
|
||||
buildManagedDreamingPatch,
|
||||
isManagedDreamingJob,
|
||||
resolveCronServiceFromStartupEvent,
|
||||
resolveCronServiceFromGatewayContext,
|
||||
constants: {
|
||||
MANAGED_DREAMING_CRON_NAME,
|
||||
MANAGED_DREAMING_CRON_TAG,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
PluginHookGatewayContext,
|
||||
PluginHookGatewayStartEvent,
|
||||
} from "../plugins/hook-types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const startPluginServices = vi.fn(async () => null);
|
||||
@@ -260,6 +264,57 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes typed gateway_start context with config, workspace dir, and a live cron getter", async () => {
|
||||
const runGatewayStart = vi.fn<
|
||||
(event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const hookRunner = {
|
||||
hasHooks: vi.fn((hookName: string) => hookName === "gateway_start"),
|
||||
runGatewayStart,
|
||||
};
|
||||
const initialCron = { list: vi.fn(), add: vi.fn(), update: vi.fn(), remove: vi.fn() };
|
||||
const params = createPostAttachParams({
|
||||
gatewayPluginConfigAtStart: {
|
||||
hooks: { internal: { enabled: false } },
|
||||
plugins: { entries: { demo: { enabled: true } } },
|
||||
} as never,
|
||||
deps: { cron: initialCron } as never,
|
||||
});
|
||||
|
||||
await startGatewayPostAttachRuntime(
|
||||
params,
|
||||
createPostAttachRuntimeDeps({
|
||||
getGlobalHookRunner: vi.fn(async () => hookRunner as never),
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(runGatewayStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const firstCall = runGatewayStart.mock.calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("gateway_start was not invoked");
|
||||
}
|
||||
const [event, ctx] = firstCall;
|
||||
expect(event).toEqual({ port: 18789 });
|
||||
expect(ctx).toMatchObject({
|
||||
port: 18789,
|
||||
config: params.gatewayPluginConfigAtStart,
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
expect(typeof ctx.getCron).toBe("function");
|
||||
const getCron = ctx.getCron;
|
||||
if (!getCron) {
|
||||
throw new Error("gateway_start context did not expose getCron");
|
||||
}
|
||||
expect(getCron()).toBe(initialCron);
|
||||
|
||||
const reloadedCron = { list: vi.fn(), add: vi.fn(), update: vi.fn(), remove: vi.fn() };
|
||||
params.deps.cron = reloadedCron as never;
|
||||
expect(getCron()).toBe(reloadedCron);
|
||||
});
|
||||
});
|
||||
|
||||
function createPostAttachRuntimeDeps(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { hasConfiguredInternalHooks } from "../hooks/configured.js";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import type { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
||||
import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import type { PluginHookGatewayCronService } from "../plugins/hook-types.js";
|
||||
import type { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||
import {
|
||||
@@ -474,7 +475,15 @@ export async function startGatewayPostAttachRuntime(
|
||||
const hookRunner = await runtimeDeps.getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("gateway_start")) {
|
||||
void hookRunner
|
||||
.runGatewayStart({ port: params.port }, { port: params.port })
|
||||
.runGatewayStart(
|
||||
{ port: params.port },
|
||||
{
|
||||
port: params.port,
|
||||
config: params.gatewayPluginConfigAtStart,
|
||||
workspaceDir: params.defaultWorkspaceDir,
|
||||
getCron: () => params.deps.cron as PluginHookGatewayCronService | undefined,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
params.log.warn(`gateway_start hook failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
@@ -475,6 +475,9 @@ export type PluginHookSubagentEndedEvent = {
|
||||
|
||||
export type PluginHookGatewayContext = {
|
||||
port?: number;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
getCron?: () => PluginHookGatewayCronService | undefined;
|
||||
};
|
||||
|
||||
export type PluginHookGatewayStartEvent = {
|
||||
@@ -485,6 +488,55 @@ export type PluginHookGatewayStopEvent = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PluginHookGatewayCronJob = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
schedule?: {
|
||||
kind?: string;
|
||||
expr?: string;
|
||||
tz?: string;
|
||||
};
|
||||
sessionTarget?: string;
|
||||
wakeMode?: string;
|
||||
payload?: {
|
||||
kind?: string;
|
||||
text?: string;
|
||||
};
|
||||
createdAtMs?: number;
|
||||
};
|
||||
|
||||
export type PluginHookGatewayCronCreateInput = {
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
schedule: {
|
||||
kind: string;
|
||||
expr: string;
|
||||
tz?: string;
|
||||
};
|
||||
sessionTarget: string;
|
||||
wakeMode: string;
|
||||
payload: {
|
||||
kind: string;
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PluginHookGatewayCronUpdateInput = Partial<PluginHookGatewayCronCreateInput>;
|
||||
|
||||
export type PluginHookGatewayCronRemoveResult = {
|
||||
removed?: boolean;
|
||||
};
|
||||
|
||||
export type PluginHookGatewayCronService = {
|
||||
list: (opts?: { includeDisabled?: boolean }) => Promise<PluginHookGatewayCronJob[]>;
|
||||
add: (input: PluginHookGatewayCronCreateInput) => Promise<unknown>;
|
||||
update: (id: string, patch: PluginHookGatewayCronUpdateInput) => Promise<unknown>;
|
||||
remove: (id: string) => Promise<PluginHookGatewayCronRemoveResult>;
|
||||
};
|
||||
|
||||
export type PluginInstallTargetType = "skill" | "plugin";
|
||||
export type PluginInstallRequestKind =
|
||||
| "skill-install"
|
||||
|
||||
@@ -31,7 +31,12 @@ async function expectGatewayHookCall(params: {
|
||||
}
|
||||
|
||||
describe("gateway hook runner methods", () => {
|
||||
const gatewayCtx = { port: 18789 };
|
||||
const gatewayCtx = {
|
||||
port: 18789,
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
getCron: () => undefined,
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user