mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(gateway): keep watch logs visible in tmux
This commit is contained in:
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- **Native app connection and relay reliability:** keep Android disconnects stopped across Activity recreation, fail remote camera commands without opening permission prompts, refresh mobile node registration after capability changes, surface iOS onboarding connection failures, cancel stale Talk owners on session switches, reject invalid Watch acknowledgments, preserve Watch events received during startup, and prevent older agent overview requests from replacing newer gateway state.
|
||||
- **Gateway source watch:** hand the configured port off from the installed service before starting the tmux watcher, preserve failed panes for attach/capture, and keep explicit alternate-port watches side by side with the managed Gateway.
|
||||
- **Provider network retries:** align provider read/poll/download and agent-wait recovery for transient connection errors, retry bounded provider `ENOTFOUND` failures while leaving gateway `ENOTFOUND` and non-idempotent create operations fail-fast. (#101496) Thanks @xialonglee.
|
||||
- **Session retry classification:** stop permanent provider errors whose identifiers or payload details merely contain 429/5xx digit sequences from re-sending full context, and share bounded rate-limit-window parsing across retry paths. (#105258) Thanks @destire-mio.
|
||||
- **LINE directive templates:** suppress confirms and buttons with blank required fields or unlabeled actions while preserving valid titleless buttons and surrounding reply text. (#105520) Thanks @edenfunf.
|
||||
|
||||
@@ -250,7 +250,7 @@ pnpm build
|
||||
pnpm ui:build
|
||||
```
|
||||
|
||||
`pnpm openclaw setup` writes the local config/workspace needed for `pnpm gateway:watch`. It is safe to re-run, but you normally only need it on first setup or after resetting local state. `pnpm gateway:watch` does not rebuild `dist/control-ui`, so rerun `pnpm ui:build` after `ui/` changes or use `pnpm ui:dev` when iterating on the Control UI. If you want this checkout to run onboarding directly, use `pnpm openclaw onboard --install-daemon`.
|
||||
`pnpm openclaw setup` writes the local config/workspace needed for `pnpm gateway:watch`. It is safe to re-run, but you normally only need it on first setup or after resetting local state. `pnpm gateway:watch` hands the configured Gateway port from the installed service to a durable tmux pane; run `pnpm openclaw gateway start` when you want the installed service back. It does not rebuild `dist/control-ui`, so rerun `pnpm ui:build` after `ui/` changes or use `pnpm ui:dev` when iterating on the Control UI. If you want this checkout to run onboarding directly, use `pnpm openclaw onboard --install-daemon`.
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary, while `pnpm gateway:watch` rebuilds the runtime on demand during the dev loop.
|
||||
|
||||
|
||||
@@ -84,21 +84,25 @@ By default this starts or restarts a tmux session named `openclaw-gateway-watch-
|
||||
|
||||
```bash
|
||||
tmux attach -t openclaw-gateway-watch-main
|
||||
# Read recent output without attaching
|
||||
tmux capture-pane -ep -t openclaw-gateway-watch-main -S -200
|
||||
```
|
||||
|
||||
The pane uses tmux `remain-on-exit`, so startup failures stay available for attach or capture instead of deleting the session. Re-running `pnpm gateway:watch` respawns that pane.
|
||||
|
||||
The tmux pane runs the raw watcher:
|
||||
|
||||
```bash
|
||||
node scripts/watch-node.mjs gateway --force
|
||||
```
|
||||
|
||||
Stop an installed Gateway service before watching the same port:
|
||||
Before watching the configured/default port, the tmux wrapper stops the active profile's installed Gateway service. This hands the port to the source watcher without launchd, systemd, or Scheduled Task respawning and replacing it. The service stays installed; restore it after the watch session with:
|
||||
|
||||
```bash
|
||||
pnpm openclaw gateway stop
|
||||
pnpm openclaw gateway start
|
||||
```
|
||||
|
||||
The watcher's `--force` clears the current listener, but it does not disable a supervised service. A launchd, systemd, or Scheduled Task service can otherwise respawn and replace the watched Gateway.
|
||||
When an explicit `--port` or `OPENCLAW_GATEWAY_PORT` differs from the installed service's effective port, the wrapper leaves the service running so both Gateways can run side by side.
|
||||
|
||||
Foreground mode without tmux:
|
||||
|
||||
@@ -108,6 +112,8 @@ pnpm gateway:watch:raw
|
||||
OPENCLAW_GATEWAY_WATCH_TMUX=0 pnpm gateway:watch
|
||||
```
|
||||
|
||||
Raw mode does not manage the installed service. Run `pnpm openclaw gateway stop` first when it uses the same port.
|
||||
|
||||
Keep tmux management but disable auto-attach:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -104,6 +104,11 @@ terminals. Non-interactive shells stay detached and print
|
||||
`tmux attach -t openclaw-gateway-watch-main`; use
|
||||
`OPENCLAW_GATEWAY_WATCH_ATTACH=0 pnpm gateway:watch` to keep an interactive run
|
||||
detached, or `pnpm gateway:watch:raw` for foreground watch mode. The watcher
|
||||
stops the active profile's installed Gateway service before taking over its
|
||||
configured/default port, preventing the service supervisor from replacing the
|
||||
source process. The service stays installed; run `pnpm openclaw gateway start`
|
||||
when you finish watching. The tmux pane remains available after startup failure
|
||||
so another terminal or agent can attach or capture its logs. The watcher
|
||||
reloads on relevant source, config, and bundled-plugin metadata changes. If the
|
||||
watched Gateway exits during startup, `gateway:watch` runs
|
||||
`openclaw doctor --fix --non-interactive` once and retries; set
|
||||
|
||||
@@ -11,6 +11,25 @@ export function buildGatewayWatchTmuxCommand(params?: {
|
||||
sessionName?: string;
|
||||
}): string;
|
||||
|
||||
export function runGatewayWatchServiceHandoff(params?: {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodePath?: string;
|
||||
spawnSync?: (
|
||||
cmd: string,
|
||||
args: string[],
|
||||
options: unknown,
|
||||
) => {
|
||||
error?: Error;
|
||||
signal?: NodeJS.Signals | null;
|
||||
status?: number | null;
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
};
|
||||
stderr?: { write: (message: string) => void };
|
||||
}): number;
|
||||
|
||||
export function runGatewayWatchTmuxMain(params?: {
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
|
||||
+194
-11
@@ -15,6 +15,10 @@ const RUN_NODE_CPU_PROF_MAX_FILES_ENV = "OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES";
|
||||
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
|
||||
const RUN_NODE_FILTER_SYNC_IO_STDERR_ENV = "OPENCLAW_RUN_NODE_FILTER_SYNC_IO_STDERR";
|
||||
const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs";
|
||||
const RUN_NODE_SCRIPT = "scripts/run-node.mjs";
|
||||
const GATEWAY_WATCH_TMUX_SCRIPT = "scripts/gateway-watch-tmux.mjs";
|
||||
const SERVICE_HANDOFF_ARG = "--handoff-managed-service";
|
||||
const DEFAULT_GATEWAY_PORT = "18789";
|
||||
const TMUX_CWD_ENV_KEY = "OPENCLAW_GATEWAY_WATCH_CWD";
|
||||
const TMUX_CWD_OPTION_KEY = "@openclaw.gateway_watch.cwd";
|
||||
const TMUX_CHILD_ENV_KEYS = [
|
||||
@@ -64,6 +68,52 @@ const readArgValue = (args, flag) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasArg = (args, flag) =>
|
||||
args.some((arg) => arg === flag || (typeof arg === "string" && arg.startsWith(`${flag}=`)));
|
||||
|
||||
const parsePortValue = (raw, { allowHost = false } = {}) => {
|
||||
const trimmed = String(raw ?? "").trim();
|
||||
let portText = /^\d+$/.test(trimmed) ? trimmed : null;
|
||||
if (!portText && allowHost) {
|
||||
const bracketed = trimmed.match(/^\[[^\]]+\]:(\d+)$/);
|
||||
if (bracketed?.[1]) {
|
||||
portText = bracketed[1];
|
||||
} else {
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
const lastColon = trimmed.lastIndexOf(":");
|
||||
const suffix =
|
||||
firstColon > 0 && firstColon === lastColon ? trimmed.slice(firstColon + 1) : "";
|
||||
portText = /^\d+$/.test(suffix) ? suffix : null;
|
||||
}
|
||||
}
|
||||
const port = portText ? Number(portText) : Number.NaN;
|
||||
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
|
||||
};
|
||||
|
||||
const resolveGatewayWatchPort = ({ args, env }) => {
|
||||
// Keep CLI precedence and Compose-style env parsing aligned with the Gateway
|
||||
// owners in src/cli/gateway-cli/run.ts and src/config/paths.ts.
|
||||
if (hasArg(args, "--port")) {
|
||||
return { explicitCli: true, port: parsePortValue(readArgValue(args, "--port")) };
|
||||
}
|
||||
return {
|
||||
explicitCli: false,
|
||||
port: parsePortValue(env.OPENCLAW_GATEWAY_PORT, { allowHost: true }),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveGatewayWatchProfile = ({ args, env }) => {
|
||||
if (hasArg(args, "--profile")) {
|
||||
return readArgValue(args, "--profile") ?? "";
|
||||
}
|
||||
const gatewayIndex = args.indexOf("gateway");
|
||||
const devIndex = args.indexOf("--dev");
|
||||
if (devIndex >= 0 && (gatewayIndex < 0 || devIndex < gatewayIndex)) {
|
||||
return "dev";
|
||||
}
|
||||
return env.OPENCLAW_PROFILE || null;
|
||||
};
|
||||
|
||||
const joinArtifactPath = (dir, basename) => {
|
||||
const normalizedDir = String(dir || DEFAULT_BENCHMARK_PROFILE_DIR).replace(/[\\/]+$/g, "");
|
||||
return `${normalizedDir || "."}/${basename}`;
|
||||
@@ -144,18 +194,15 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
|
||||
* Resolves the tmux session name for gateway watch arguments/environment.
|
||||
*/
|
||||
export const resolveGatewayWatchTmuxSessionName = ({ args = [], env = process.env } = {}) => {
|
||||
const profile =
|
||||
env.OPENCLAW_PROFILE ||
|
||||
readArgValue(args, "--profile") ||
|
||||
(args.includes("--dev") ? "dev" : null);
|
||||
const port = env.OPENCLAW_GATEWAY_PORT || readArgValue(args, "--port");
|
||||
const profile = resolveGatewayWatchProfile({ args, env });
|
||||
const { port } = resolveGatewayWatchPort({ args, env });
|
||||
const parts = [
|
||||
"openclaw",
|
||||
"gateway",
|
||||
"watch",
|
||||
sanitizeSessionPart(profile ?? DEFAULT_PROFILE_NAME),
|
||||
];
|
||||
if (port && port !== "18789") {
|
||||
if (port && String(port) !== DEFAULT_GATEWAY_PORT) {
|
||||
parts.push(sanitizeSessionPart(port));
|
||||
}
|
||||
return parts.join("-");
|
||||
@@ -199,12 +246,22 @@ export const buildGatewayWatchTmuxCommand = ({
|
||||
env[key] == null || env[key] === "" ? [] : [`${key}=${env[key]}`],
|
||||
),
|
||||
];
|
||||
const childEnvCommand = childEnv.map(shellQuote);
|
||||
const handoffCommand = [
|
||||
...childEnvCommand,
|
||||
shellQuote(nodePath),
|
||||
shellQuote(GATEWAY_WATCH_TMUX_SCRIPT),
|
||||
shellQuote(SERVICE_HANDOFF_ARG),
|
||||
...args.map(shellQuote),
|
||||
"&&",
|
||||
];
|
||||
const watchCommand = [
|
||||
"cd",
|
||||
shellQuote(cwd),
|
||||
"&&",
|
||||
...handoffCommand,
|
||||
"exec",
|
||||
...childEnv.map(shellQuote),
|
||||
...childEnvCommand,
|
||||
shellQuote(nodePath),
|
||||
shellQuote(RAW_WATCH_SCRIPT),
|
||||
...args.map(shellQuote),
|
||||
@@ -212,6 +269,96 @@ export const buildGatewayWatchTmuxCommand = ({
|
||||
return `exec ${shellQuote(shell)} -lc ${shellQuote(watchCommand)}`;
|
||||
};
|
||||
|
||||
const parseTrailingJsonObject = (raw) => {
|
||||
const text = String(raw ?? "").trim();
|
||||
for (let index = text.lastIndexOf("{"); index >= 0; index = text.lastIndexOf("{", index - 1)) {
|
||||
try {
|
||||
return JSON.parse(text.slice(index));
|
||||
} catch {
|
||||
// Build output can precede the CLI JSON; keep scanning for the outer object.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Stops the matching managed service without targeting an unrelated listener. */
|
||||
export const runGatewayWatchServiceHandoff = (params = {}) => {
|
||||
const args = params.args ?? [];
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const env = params.env ? { ...params.env } : { ...process.env };
|
||||
const nodePath = params.nodePath ?? process.execPath;
|
||||
const spawnSyncImpl = params.spawnSync ?? spawnSync;
|
||||
const stderr = params.stderr ?? process.stderr;
|
||||
const profile = resolveGatewayWatchProfile({ args, env });
|
||||
const profileArgs = profile === null ? [] : ["--profile", profile];
|
||||
const statusResult = spawnSyncImpl(
|
||||
nodePath,
|
||||
[RUN_NODE_SCRIPT, ...profileArgs, "gateway", "status", "--json", "--no-probe"],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
if (statusResult.error || statusResult.status !== 0) {
|
||||
const detail =
|
||||
statusResult.error?.message || String(statusResult.stderr || "").trim() || "unknown error";
|
||||
log(stderr, `failed to inspect the managed Gateway service before watch: ${detail}`);
|
||||
return statusResult.status || 1;
|
||||
}
|
||||
const status = parseTrailingJsonObject(statusResult.stdout);
|
||||
if (!status || typeof status !== "object") {
|
||||
log(stderr, "failed to parse managed Gateway service status before watch");
|
||||
return 1;
|
||||
}
|
||||
const managedServiceActive =
|
||||
status.service?.loaded === true || status.service?.runtime?.status === "running";
|
||||
if (!managedServiceActive) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { explicitCli, port: requestedPort } = resolveGatewayWatchPort({ args, env });
|
||||
if (explicitCli && requestedPort === null) {
|
||||
// The watcher will report the invalid CLI value without disrupting a healthy service.
|
||||
return 0;
|
||||
}
|
||||
const managedPort = parsePortValue(status.gateway?.port);
|
||||
const currentConfigPort = parsePortValue(status.portCli?.port ?? status.gateway?.port);
|
||||
const watchPort = requestedPort ?? currentConfigPort;
|
||||
if (managedPort === null || watchPort === null) {
|
||||
log(stderr, "failed to resolve the Gateway watch port before service handoff");
|
||||
return 1;
|
||||
}
|
||||
if (watchPort !== managedPort) {
|
||||
log(
|
||||
stderr,
|
||||
`gateway:watch leaving managed Gateway on port ${managedPort}; watching port ${watchPort}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If the service unloads after status, keep the unmanaged fallback scoped to
|
||||
// the watch target instead of a lower-precedence environment port.
|
||||
const stopEnv = { ...env };
|
||||
if (explicitCli) {
|
||||
stopEnv.OPENCLAW_GATEWAY_PORT = String(watchPort);
|
||||
}
|
||||
const stopResult = spawnSyncImpl(nodePath, [RUN_NODE_SCRIPT, ...profileArgs, "gateway", "stop"], {
|
||||
cwd,
|
||||
env: stopEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (stopResult.error) {
|
||||
log(
|
||||
stderr,
|
||||
`failed to stop the managed Gateway service before watch: ${stopResult.error.message}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return stopResult.status ?? (stopResult.signal ? 1 : 0);
|
||||
};
|
||||
|
||||
const runForegroundWatcher = ({ args, cwd, env, nodePath, spawnSyncImpl, stdio = "inherit" }) => {
|
||||
const result = spawnSyncImpl(nodePath, [RAW_WATCH_SCRIPT, ...args], {
|
||||
cwd,
|
||||
@@ -279,6 +426,9 @@ const setTmuxSessionMetadata = ({ cwd, sessionName, spawnSyncImpl, stderr }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const retainTmuxPaneOnExit = ({ sessionName, spawnSyncImpl }) =>
|
||||
runTmux(spawnSyncImpl, ["set-option", "-w", "-t", sessionName, "remain-on-exit", "on"]);
|
||||
|
||||
/**
|
||||
* Runs the gateway-watch tmux wrapper main flow.
|
||||
*/
|
||||
@@ -355,10 +505,37 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const startSession = () =>
|
||||
runTmux(deps.spawnSync, ["new-session", "-d", "-s", sessionName, "-c", deps.cwd, command]);
|
||||
const restartSession = () =>
|
||||
const launchPane = () =>
|
||||
runTmux(deps.spawnSync, ["respawn-pane", "-k", "-t", sessionName, "-c", deps.cwd, command]);
|
||||
const prepareSession = () => retainTmuxPaneOnExit({ sessionName, spawnSyncImpl: deps.spawnSync });
|
||||
const startSession = () => {
|
||||
// Create a durable shell pane first so remain-on-exit is active before the
|
||||
// watcher can fail. Agents can then capture the original startup error.
|
||||
const created = runTmux(deps.spawnSync, [
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
sessionName,
|
||||
"-c",
|
||||
deps.cwd,
|
||||
]);
|
||||
if (created.error || created.status !== 0) {
|
||||
return created;
|
||||
}
|
||||
const prepared = prepareSession();
|
||||
if (prepared.error || prepared.status !== 0) {
|
||||
runTmux(deps.spawnSync, ["kill-session", "-t", sessionName]);
|
||||
return prepared;
|
||||
}
|
||||
return launchPane();
|
||||
};
|
||||
const restartSession = () => {
|
||||
const prepared = prepareSession();
|
||||
if (prepared.error || prepared.status !== 0) {
|
||||
return prepared;
|
||||
}
|
||||
return launchPane();
|
||||
};
|
||||
const action = hasSession.status === 0 ? "restarted" : "started";
|
||||
let result = hasSession.status === 0 ? restartSession() : startSession();
|
||||
if (hasSession.status === 0 && isMissingTmuxTarget(result)) {
|
||||
@@ -410,6 +587,7 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
return 0;
|
||||
}
|
||||
deps.stdout.write(`Attach: tmux attach -t ${sessionName}\n`);
|
||||
deps.stdout.write(`Logs: tmux capture-pane -ep -t ${sessionName} -S -200\n`);
|
||||
deps.stdout.write(`Cwd: tmux show-options -v -t ${sessionName} ${TMUX_CWD_OPTION_KEY}\n`);
|
||||
deps.stdout.write("Restart: rerun the same pnpm gateway:watch command\n");
|
||||
deps.stdout.write(`Stop: tmux kill-session -t ${sessionName}\n`);
|
||||
@@ -417,5 +595,10 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
};
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exit(runGatewayWatchTmuxMain());
|
||||
const args = process.argv.slice(2);
|
||||
process.exit(
|
||||
args[0] === SERVICE_HANDOFF_ARG
|
||||
? runGatewayWatchServiceHandoff({ args: args.slice(1) })
|
||||
: runGatewayWatchTmuxMain({ args }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ type RestartParams = {
|
||||
|
||||
const service = {
|
||||
readCommand: vi.fn(),
|
||||
readRuntime: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
const runServiceStart = vi.fn();
|
||||
@@ -202,7 +204,10 @@ describe("runDaemonRestart health checks", () => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_CONTAINER_HINT", "OPENCLAW_PROFILE"]);
|
||||
delete process.env.OPENCLAW_CONTAINER_HINT;
|
||||
service.readCommand.mockReset();
|
||||
service.readRuntime.mockReset();
|
||||
service.readRuntime.mockResolvedValue({ status: "stopped" });
|
||||
service.restart.mockReset();
|
||||
service.stop.mockReset();
|
||||
runServiceStart.mockReset();
|
||||
runServiceRestart.mockReset();
|
||||
runServiceStop.mockReset();
|
||||
@@ -535,9 +540,13 @@ describe("runDaemonRestart health checks", () => {
|
||||
|
||||
it("signals an unmanaged gateway process on stop", async () => {
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4200, 4300]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
@@ -559,6 +568,23 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(stopParams.stopWhenNotLoaded).toBe(true);
|
||||
});
|
||||
|
||||
it("stops a running disabled systemd unit through the service manager", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
service.readRuntime.mockResolvedValue({ status: "running" });
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
expect(service.stop).toHaveBeenCalledWith({ env: process.env, stdout: process.stdout });
|
||||
expect(findVerifiedGatewayListenerPidsOnPortSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips gateway port resolution on stop when the service manager handles the stop", async () => {
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
@@ -739,9 +765,13 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
stopSystemdService.mockResolvedValue(undefined);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
|
||||
await expect(runDaemonStop({ json: true })).resolves.toBeUndefined();
|
||||
expect(stopSystemdService).toHaveBeenCalled();
|
||||
@@ -761,9 +791,13 @@ describe("runDaemonRestart health checks", () => {
|
||||
),
|
||||
);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
|
||||
await expect(runDaemonStop({ json: true })).rejects.toThrow(
|
||||
/sudo systemctl stop openclaw-gateway\.service/,
|
||||
@@ -774,9 +808,13 @@ describe("runDaemonRestart health checks", () => {
|
||||
|
||||
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
|
||||
@@ -313,7 +313,16 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
||||
service,
|
||||
opts,
|
||||
stopWhenNotLoaded: process.platform === "darwin" && Boolean(opts.disable),
|
||||
onNotLoaded: async () => {
|
||||
onNotLoaded: async ({ stdout }) => {
|
||||
if (process.platform === "linux") {
|
||||
const runtime = await service.readRuntime(process.env).catch(() => null);
|
||||
if (runtime?.status === "running") {
|
||||
// systemd can run a disabled unit with Restart=always. Stop it through
|
||||
// systemctl so a process-level SIGTERM cannot trigger a respawn.
|
||||
await service.stop({ env: process.env, stdout });
|
||||
return { result: "stopped" };
|
||||
}
|
||||
}
|
||||
gatewayPortPromise ??= resolveGatewayLifecyclePort(service).catch(() =>
|
||||
resolveGatewayPortFallback(),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildGatewayWatchTmuxCommand,
|
||||
resolveGatewayWatchTmuxSessionName,
|
||||
runGatewayWatchTmuxMain,
|
||||
runGatewayWatchServiceHandoff,
|
||||
} from "../../scripts/gateway-watch-tmux.mjs";
|
||||
|
||||
const createOutput = () => {
|
||||
@@ -34,13 +35,15 @@ function spawnCall(mock: unknown, callIndex: number) {
|
||||
return call;
|
||||
}
|
||||
|
||||
function spawnShellCommand(mock: unknown, callIndex: number): string {
|
||||
const call = spawnCall(mock, callIndex);
|
||||
const args = call[1];
|
||||
if (!Array.isArray(args) || typeof args[6] !== "string") {
|
||||
throw new Error(`Expected spawn call ${callIndex + 1} shell command`);
|
||||
function spawnShellCommand(mock: unknown): string {
|
||||
const calls = (mock as { mock?: { calls?: Array<Array<unknown>> } }).mock?.calls ?? [];
|
||||
for (const call of calls) {
|
||||
const args = call[1];
|
||||
if (Array.isArray(args) && typeof args[6] === "string") {
|
||||
return args[6];
|
||||
}
|
||||
}
|
||||
return args[6];
|
||||
throw new Error("Expected a tmux shell command");
|
||||
}
|
||||
|
||||
function expectSpawn(mock: unknown, callIndex: number, command: string, args: Array<unknown>) {
|
||||
@@ -67,6 +70,24 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
env: {},
|
||||
}),
|
||||
).toBe("openclaw-gateway-watch-dev");
|
||||
expect(
|
||||
resolveGatewayWatchTmuxSessionName({
|
||||
args: ["gateway", "--dev", "--port=18789"],
|
||||
env: {},
|
||||
}),
|
||||
).toBe("openclaw-gateway-watch-main");
|
||||
expect(
|
||||
resolveGatewayWatchTmuxSessionName({
|
||||
args: ["gateway", "--profile", "work", "--port=18789"],
|
||||
env: { OPENCLAW_PROFILE: "main" },
|
||||
}),
|
||||
).toBe("openclaw-gateway-watch-work");
|
||||
expect(
|
||||
resolveGatewayWatchTmuxSessionName({
|
||||
args: ["gateway", "--force"],
|
||||
env: { OPENCLAW_GATEWAY_PORT: "127.0.0.1:18789" },
|
||||
}),
|
||||
).toBe("openclaw-gateway-watch-main");
|
||||
});
|
||||
|
||||
it("builds a login-shell command that runs the raw watcher in the repo", () => {
|
||||
@@ -104,6 +125,215 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
expect(command).toContain("gateway");
|
||||
expect(command).toContain("--force");
|
||||
expect(command).toContain("'a b.jsonl'");
|
||||
expect(command).not.toContain("scripts/run-node.mjs");
|
||||
expect(command).toContain("scripts/gateway-watch-tmux.mjs");
|
||||
expect(command).toContain("--handoff-managed-service");
|
||||
});
|
||||
|
||||
it("runs managed service handoff before watching", () => {
|
||||
const command = buildGatewayWatchTmuxCommand({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/repo",
|
||||
env: { SHELL: "/bin/zsh" },
|
||||
nodePath: "/opt/node",
|
||||
sessionName: "openclaw-gateway-watch-main",
|
||||
});
|
||||
|
||||
expect(command).toContain("scripts/gateway-watch-tmux.mjs");
|
||||
expect(command).toMatch(
|
||||
/gateway-watch-tmux\.mjs.*handoff-managed-service.*&& exec.*scripts\/watch-node\.mjs/,
|
||||
);
|
||||
expect(command.indexOf("scripts/gateway-watch-tmux.mjs")).toBeLessThan(
|
||||
command.indexOf("scripts/watch-node.mjs"),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops a loaded service on the watched port with CLI selector precedence", () => {
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: `build output\n${JSON.stringify({
|
||||
service: { loaded: true },
|
||||
gateway: { port: 18789 },
|
||||
})}`,
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force", "--port", "18789", "--profile", "work"],
|
||||
cwd: "/repo",
|
||||
env: { OPENCLAW_GATEWAY_PORT: "19001", OPENCLAW_PROFILE: "main" },
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expectSpawn(spawnSync, 0, "/opt/node", [
|
||||
"scripts/run-node.mjs",
|
||||
"--profile",
|
||||
"work",
|
||||
"gateway",
|
||||
"status",
|
||||
"--json",
|
||||
"--no-probe",
|
||||
]);
|
||||
const stopOptions = expectSpawn(spawnSync, 1, "/opt/node", [
|
||||
"scripts/run-node.mjs",
|
||||
"--profile",
|
||||
"work",
|
||||
"gateway",
|
||||
"stop",
|
||||
]);
|
||||
expect(requireRecord(stopOptions.env, "stop env").OPENCLAW_GATEWAY_PORT).toBe("18789");
|
||||
expect(stopOptions.stdio).toBe("inherit");
|
||||
});
|
||||
|
||||
it("leaves a loaded managed Gateway running on a different port", () => {
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ service: { loaded: true }, gateway: { port: 18789 } }),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force", "--port=19001"],
|
||||
cwd: "/repo",
|
||||
env: { OPENCLAW_GATEWAY_PORT: "18789" },
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
expect(stderr.chunks.join("")).toContain(
|
||||
"leaving managed Gateway on port 18789; watching port 19001",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the current config port when the installed service retains an old port", () => {
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
service: { loaded: true },
|
||||
gateway: { port: 18789 },
|
||||
portCli: { port: 19001 },
|
||||
}),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
expect(stderr.chunks.join("")).toContain(
|
||||
"leaving managed Gateway on port 18789; watching port 19001",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes a Compose-style port before comparing with the managed service", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ service: { loaded: true }, gateway: { port: 18789 } }),
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/repo",
|
||||
env: { OPENCLAW_GATEWAY_PORT: "127.0.0.1:18789" },
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not invoke the unmanaged stop fallback when the service is not loaded", () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ service: { loaded: false }, gateway: { port: 18789 } }),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops a running managed service even when systemd reports it disabled", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
service: { loaded: false, runtime: { status: "running" } },
|
||||
gateway: { port: 18789 },
|
||||
}),
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({ status: 0 });
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails handoff when the managed stop process cannot launch", () => {
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ service: { loaded: true }, gateway: { port: 18789 } }),
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({ error: new Error("spawn failed"), status: null, signal: null });
|
||||
|
||||
const code = runGatewayWatchServiceHandoff({
|
||||
args: ["gateway", "--force"],
|
||||
cwd: "/repo",
|
||||
env: {},
|
||||
nodePath: "/opt/node",
|
||||
spawnSync,
|
||||
stderr: stderr.stream,
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(stderr.chunks.join("")).toContain(
|
||||
"failed to stop the managed Gateway service before watch: spawn failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("consumes benchmark flags and passes the CPU profile dir to the watched child", () => {
|
||||
@@ -112,9 +342,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--benchmark"],
|
||||
@@ -127,7 +355,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const command = spawnShellCommand(spawnSync, 1);
|
||||
const command = spawnShellCommand(spawnSync);
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'");
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES=40'");
|
||||
expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=0'");
|
||||
@@ -145,9 +373,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--benchmark"],
|
||||
@@ -160,7 +386,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const command = spawnShellCommand(spawnSync, 1);
|
||||
const command = spawnShellCommand(spawnSync);
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_MAX_FILES=8'");
|
||||
});
|
||||
|
||||
@@ -170,9 +396,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--benchmark"],
|
||||
@@ -185,7 +409,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const command = spawnShellCommand(spawnSync, 1);
|
||||
const command = spawnShellCommand(spawnSync);
|
||||
expect(command).toContain("'OPENCLAW_TRACE_SYNC_IO=1'");
|
||||
expect(command).toContain(
|
||||
"'OPENCLAW_RUN_NODE_OUTPUT_LOG=.artifacts/gateway-watch-profiles/gateway-watch-output.log'",
|
||||
@@ -202,9 +426,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--benchmark-no-force"],
|
||||
@@ -217,7 +439,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const command = spawnShellCommand(spawnSync, 1);
|
||||
const command = spawnShellCommand(spawnSync);
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'");
|
||||
expect(command).not.toContain("--benchmark-no-force");
|
||||
expect(command).toContain("'gateway'");
|
||||
@@ -249,9 +471,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force"],
|
||||
@@ -279,10 +499,33 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
"-c",
|
||||
"/repo",
|
||||
]);
|
||||
expect(String(newSessionArgs[6])).toContain("scripts/watch-node.mjs");
|
||||
expect(newSessionArgs).toHaveLength(6);
|
||||
expect(requireRecord(newSessionCall[2], "spawn options").encoding).toBe("utf8");
|
||||
expect(
|
||||
expectSpawn(spawnSync, 2, "tmux", [
|
||||
"set-option",
|
||||
"-w",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-main",
|
||||
"remain-on-exit",
|
||||
"on",
|
||||
]).encoding,
|
||||
).toBe("utf8");
|
||||
const launchCall = spawnCall(spawnSync, 3);
|
||||
expect(launchCall[0]).toBe("tmux");
|
||||
const launchArgs = launchCall[1] as Array<unknown>;
|
||||
expect(launchArgs.slice(0, 6)).toEqual([
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-main",
|
||||
"-c",
|
||||
"/repo",
|
||||
]);
|
||||
expect(String(launchArgs[6])).toContain("scripts/gateway-watch-tmux.mjs");
|
||||
expect(String(launchArgs[6])).toContain("scripts/watch-node.mjs");
|
||||
expect(
|
||||
expectSpawn(spawnSync, 4, "tmux", [
|
||||
"set-option",
|
||||
"-q",
|
||||
"-t",
|
||||
@@ -292,7 +535,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
]).encoding,
|
||||
).toBe("utf8");
|
||||
expect(
|
||||
expectSpawn(spawnSync, 3, "tmux", [
|
||||
expectSpawn(spawnSync, 5, "tmux", [
|
||||
"set-environment",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-main",
|
||||
@@ -304,6 +547,9 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
"gateway:watch started in tmux session openclaw-gateway-watch-main",
|
||||
);
|
||||
expect(stdout.chunks.join("")).toContain("tmux attach -t openclaw-gateway-watch-main");
|
||||
expect(stdout.chunks.join("")).toContain(
|
||||
"tmux capture-pane -ep -t openclaw-gateway-watch-main -S -200",
|
||||
);
|
||||
expect(stdout.chunks.join("")).toContain(
|
||||
"tmux show-options -v -t openclaw-gateway-watch-main @openclaw.gateway_watch.cwd",
|
||||
);
|
||||
@@ -315,10 +561,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force"],
|
||||
@@ -334,7 +577,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(
|
||||
expectSpawn(spawnSync, 4, "tmux", ["attach-session", "-t", "openclaw-gateway-watch-main"])
|
||||
expectSpawn(spawnSync, 6, "tmux", ["attach-session", "-t", "openclaw-gateway-watch-main"])
|
||||
.stdio,
|
||||
).toBe("inherit");
|
||||
expect(stdout.chunks.join("")).not.toContain("tmux attach -t");
|
||||
@@ -348,9 +591,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force"],
|
||||
@@ -365,7 +606,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(4);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(6);
|
||||
expect(stdout.chunks.join("")).toContain("tmux attach -t openclaw-gateway-watch-main");
|
||||
},
|
||||
);
|
||||
@@ -373,13 +614,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
it("switches tmux clients instead of nesting attach when already inside tmux", () => {
|
||||
const stdout = createOutput();
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
const spawnSync = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force"],
|
||||
@@ -395,7 +630,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(
|
||||
expectSpawn(spawnSync, 4, "tmux", ["switch-client", "-t", "openclaw-gateway-watch-main"])
|
||||
expectSpawn(spawnSync, 5, "tmux", ["switch-client", "-t", "openclaw-gateway-watch-main"])
|
||||
.stdio,
|
||||
).toBe("inherit");
|
||||
});
|
||||
@@ -406,9 +641,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force"],
|
||||
@@ -423,19 +656,14 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(4);
|
||||
expect(spawnSync).toHaveBeenCalledTimes(6);
|
||||
expect(stdout.chunks.join("")).toContain("tmux attach -t openclaw-gateway-watch-main");
|
||||
});
|
||||
|
||||
it("respawns the existing tmux pane on repeated runs", () => {
|
||||
const stdout = createOutput();
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
const spawnSync = vi.fn().mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--port=19001"],
|
||||
@@ -452,7 +680,15 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const respawnCall = spawnCall(spawnSync, 1);
|
||||
expectSpawn(spawnSync, 1, "tmux", [
|
||||
"set-option",
|
||||
"-w",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-dev-19001",
|
||||
"remain-on-exit",
|
||||
"on",
|
||||
]);
|
||||
const respawnCall = spawnCall(spawnSync, 2);
|
||||
expect(respawnCall[0]).toBe("tmux");
|
||||
const respawnArgs = respawnCall[1] as Array<unknown>;
|
||||
expect(respawnArgs.slice(0, 6)).toEqual([
|
||||
@@ -479,10 +715,7 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "can't find window: 0" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
.mockReturnValue({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force"],
|
||||
@@ -495,19 +728,14 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const staleRespawnCall = spawnCall(spawnSync, 1);
|
||||
expect(staleRespawnCall[0]).toBe("tmux");
|
||||
const staleRespawnArgs = staleRespawnCall[1] as Array<unknown>;
|
||||
expect(staleRespawnArgs.slice(0, 6)).toEqual([
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
expectSpawn(spawnSync, 1, "tmux", [
|
||||
"set-option",
|
||||
"-w",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-main",
|
||||
"-c",
|
||||
"/repo",
|
||||
"remain-on-exit",
|
||||
"on",
|
||||
]);
|
||||
expect(String(staleRespawnArgs[6])).toContain("scripts/watch-node.mjs");
|
||||
expect(requireRecord(staleRespawnCall[2], "spawn options").encoding).toBe("utf8");
|
||||
expect(
|
||||
expectSpawn(spawnSync, 2, "tmux", ["kill-session", "-t", "openclaw-gateway-watch-main"])
|
||||
.encoding,
|
||||
@@ -523,8 +751,28 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
"-c",
|
||||
"/repo",
|
||||
]);
|
||||
expect(String(recreatedArgs[6])).toContain("scripts/watch-node.mjs");
|
||||
expect(recreatedArgs).toHaveLength(6);
|
||||
expect(requireRecord(recreatedCall[2], "spawn options").encoding).toBe("utf8");
|
||||
expectSpawn(spawnSync, 4, "tmux", [
|
||||
"set-option",
|
||||
"-w",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-main",
|
||||
"remain-on-exit",
|
||||
"on",
|
||||
]);
|
||||
const relaunchedCall = spawnCall(spawnSync, 5);
|
||||
expect(relaunchedCall[0]).toBe("tmux");
|
||||
const relaunchedArgs = relaunchedCall[1] as Array<unknown>;
|
||||
expect(relaunchedArgs.slice(0, 6)).toEqual([
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
"openclaw-gateway-watch-main",
|
||||
"-c",
|
||||
"/repo",
|
||||
]);
|
||||
expect(String(relaunchedArgs[6])).toContain("scripts/watch-node.mjs");
|
||||
});
|
||||
|
||||
it("runs the raw foreground watcher when tmux mode is disabled", () => {
|
||||
|
||||
Reference in New Issue
Block a user