Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clawnify/clawflow",
"version": "1.6.3",
"version": "1.6.4",
"description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.",
"type": "module",
"main": "./dist/index.js",
Expand Down
40 changes: 35 additions & 5 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ export function activeTriggerScheduler(): TriggerScheduler | null {
return activeScheduler;
}

/**
* Whether this process should own the box's scheduler and flow server.
*
* OpenClaw loads plugins in every process that touches config: the gateway,
* but also `openclaw --help`, `openclaw cron list`, plugin discovery, setup.
* Before this guard each of those armed a scheduler and tried to bind the
* flow port. A CLI that exits took its timers with it; one that hung (a
* diagnostic `openclaw --help` left running for 13 days on a customer box)
* became a second scheduler for the same trigger records and squatted on
* 18793 with stale code, which the real gateway then silently yielded to.
*
* Two signals, both from the host: `api.registrationMode` is "full" only
* when the host wants a complete registration (discovery / cli-metadata /
* setup-only loads never should serve), and the gateway process is the one
* started as `openclaw gateway run`. CLAWFLOW_SERVE=1 forces it on for
* tests and unusual hosts; CLAWFLOW_NO_SERVE keeps its meaning for spawned
* child agents and operator CLIs.
*/
export function shouldServe(api: { registrationMode?: string }, argv: readonly string[] = process.argv, env: NodeJS.ProcessEnv = process.env): boolean {
if (env.CLAWFLOW_NO_SERVE) return false;
if (env.CLAWFLOW_SERVE === "1") return true;
if (api.registrationMode && api.registrationMode !== "full") return false;
const args = argv.slice(2);
return args.includes("gateway") && (args.includes("run") || args.length === 1);
}

// ---- OpenClaw Plugin: clawflow ---------------------------------------------------
// Registers eleven tools:
//
Expand Down Expand Up @@ -125,18 +151,22 @@ function register(api: PluginApi) {
flowsDir,
logger: api.logger,
});
if (!process.env.CLAWFLOW_NO_SERVE) {
const serving = shouldServe(api as { registrationMode?: string });
api.logger?.info(
`clawflow: registrationMode=${(api as { registrationMode?: string }).registrationMode ?? "unknown"} argv=${process.argv.slice(2, 4).join(" ") || "-"} → ${serving ? "serving (scheduler + flow server)" : "not serving (not the gateway)"}`,
);
if (serving) {
activeScheduler?.stop();
activeScheduler = scheduler;
activeTriggerStore = triggerStore;
scheduler.start();
}

// ---- Flow server (optional) ---------------------------------------------------
// Skip when spawned as a child agent (CLAWFLOW_NO_SERVE) to avoid port conflicts.
// Started after the scheduler so its trigger routes can reach it; the
// accessor keeps them on the currently armed scheduler across reloads.
if (pluginCfg.serve && !process.env.CLAWFLOW_NO_SERVE) {
// Only the gateway process serves (see shouldServe). Started after the
// scheduler so its trigger routes can reach it; the accessor keeps them on
// the currently armed scheduler across reloads.
if (pluginCfg.serve && serving) {
startFlowServer({
runner,
serve: pluginCfg.serve,
Expand Down
1 change: 1 addition & 0 deletions tests/plugin-reregister.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as fs from "fs";
import * as path from "path";
import * as os from "os";

process.env.CLAWFLOW_SERVE = "1"; // the test process is not `openclaw gateway run`
import plugin, { activeTriggerScheduler } from "../src/plugin/index.js";
import type { FlowDefinition } from "../src/index.js";

Expand Down
33 changes: 33 additions & 0 deletions tests/should-serve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";

import { shouldServe } from "../src/plugin/index.js";

// The scheduler and flow server belong to exactly one process per box: the
// gateway. Every other load of the plugin must stay passive.
describe("shouldServe", () => {
const gateway = ["node", "/usr/bin/openclaw", "gateway", "run"];
const help = ["node", "/usr/bin/openclaw", "--help"];
const cronList = ["node", "/usr/bin/openclaw", "cron", "list", "--agent", "main"];

it("serves in the gateway process with a full registration", () => {
assert.equal(shouldServe({ registrationMode: "full" }, gateway, {}), true);
assert.equal(shouldServe({}, gateway, {}), true, "older hosts without registrationMode");
assert.equal(shouldServe({ registrationMode: "full" }, ["node", "openclaw", "gateway"], {}), true, "bare `openclaw gateway`");
});

it("stays passive in CLI, discovery and setup loads", () => {
assert.equal(shouldServe({ registrationMode: "full" }, help, {}), false, "openclaw --help");
assert.equal(shouldServe({ registrationMode: "full" }, cronList, {}), false, "openclaw cron list");
assert.equal(shouldServe({ registrationMode: "cli-metadata" }, gateway, {}), false);
assert.equal(shouldServe({ registrationMode: "discovery" }, gateway, {}), false);
assert.equal(shouldServe({ registrationMode: "setup-only" }, gateway, {}), false);
assert.equal(shouldServe({ registrationMode: "full" }, ["node", "openclaw", "gateway", "status"], {}), false, "gateway status is a client, not the daemon");
});

it("honours the explicit env switches either way", () => {
assert.equal(shouldServe({ registrationMode: "full" }, gateway, { CLAWFLOW_NO_SERVE: "1" }), false);
assert.equal(shouldServe({ registrationMode: "cli-metadata" }, help, { CLAWFLOW_SERVE: "1" }), true);
assert.equal(shouldServe({ registrationMode: "full" }, gateway, { CLAWFLOW_NO_SERVE: "1", CLAWFLOW_SERVE: "1" }), false, "NO_SERVE wins: a spawned child must never bind");
});
});
Loading