diff --git a/CHANGELOG.md b/CHANGELOG.md index 72684d290f2..3bf29fd32dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 9d756bbdd91..17ac26b0d9b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index a49accf7240..fee244269b9 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -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 diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index 4aac9a96c2a..16f6afcc7c7 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -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[0]["cron"]>; type CronJobLike = Awaited>[number]; type CronAddInput = Parameters[0]; @@ -36,7 +32,7 @@ type DreamingPluginApiTestDouble = { pluginConfig: Record; logger: ReturnType; runtime: unknown; - registerHook: (event: string, handler: Parameters[1]) => void; + registerHook: (event: string, handler: (event: unknown) => unknown) => void; on: ReturnType; }; @@ -156,6 +152,29 @@ function getBeforeAgentReplyHandler( ) => Promise; } +function getGatewayStartHandler( + onMock: ReturnType, +): ( + event: { port: number }, + ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown }, +) => Promise { + 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; +} + +async function triggerGatewayStart( + onMock: ReturnType, + ctx: { config?: OpenClawConfig; workspaceDir?: string; getCron?: () => unknown }, +): Promise { + 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[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[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[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[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[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[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[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[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[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[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 diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 0041e0dbc29..99b35bcb82e 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -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; - deps: Record | 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 => { 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, diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 12406685dd7..bd1c29b03fc 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -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 + >(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( diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index a11b4b164e2..31f3ef8df33 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -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)}`); }); diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 64667ee68cf..07b3cd926cf 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -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; + +export type PluginHookGatewayCronRemoveResult = { + removed?: boolean; +}; + +export type PluginHookGatewayCronService = { + list: (opts?: { includeDisabled?: boolean }) => Promise; + add: (input: PluginHookGatewayCronCreateInput) => Promise; + update: (id: string, patch: PluginHookGatewayCronUpdateInput) => Promise; + remove: (id: string) => Promise; +}; + export type PluginInstallTargetType = "skill" | "plugin"; export type PluginInstallRequestKind = | "skill-install" diff --git a/src/plugins/wired-hooks-gateway.test.ts b/src/plugins/wired-hooks-gateway.test.ts index 0a4d7ddf010..80f14c80f4e 100644 --- a/src/plugins/wired-hooks-gateway.test.ts +++ b/src/plugins/wired-hooks-gateway.test.ts @@ -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([ {