fix: add gateway stop/restart commands
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||||
|
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
|
||||||
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
||||||
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
||||||
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
||||||
|
|||||||
12
docs/faq.md
12
docs/faq.md
@@ -492,6 +492,9 @@ The gateway runs under a supervisor that auto-restarts it. You need to stop the
|
|||||||
# Check if running
|
# Check if running
|
||||||
launchctl list | grep clawdbot
|
launchctl list | grep clawdbot
|
||||||
|
|
||||||
|
# Stop (disable does NOT stop a running job)
|
||||||
|
clawdbot gateway stop
|
||||||
|
|
||||||
# Stop and disable
|
# Stop and disable
|
||||||
launchctl disable gui/$UID/com.clawdbot.gateway
|
launchctl disable gui/$UID/com.clawdbot.gateway
|
||||||
launchctl bootout gui/$UID/com.clawdbot.gateway
|
launchctl bootout gui/$UID/com.clawdbot.gateway
|
||||||
@@ -499,6 +502,9 @@ launchctl bootout gui/$UID/com.clawdbot.gateway
|
|||||||
# Re-enable later
|
# Re-enable later
|
||||||
launchctl enable gui/$UID/com.clawdbot.gateway
|
launchctl enable gui/$UID/com.clawdbot.gateway
|
||||||
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist
|
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist
|
||||||
|
|
||||||
|
# Or just restart
|
||||||
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux (systemd)**
|
**Linux (systemd)**
|
||||||
@@ -508,7 +514,11 @@ launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist
|
|||||||
systemctl list-units | grep -i clawdbot
|
systemctl list-units | grep -i clawdbot
|
||||||
|
|
||||||
# Stop and disable
|
# Stop and disable
|
||||||
sudo systemctl disable --now clawdbot
|
clawdbot gateway stop
|
||||||
|
systemctl --user disable --now clawdbot-gateway.service
|
||||||
|
|
||||||
|
# Or just restart
|
||||||
|
clawdbot gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
**pm2 (if used)**
|
**pm2 (if used)**
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ See also: `docs/presence.md` for how presence is produced/deduped and why `insta
|
|||||||
|
|
||||||
Bundled mac app:
|
Bundled mac app:
|
||||||
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
|
- Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`.
|
||||||
|
- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`).
|
||||||
|
- To restart, use `clawdbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`).
|
||||||
|
|
||||||
## Supervision (systemd user unit)
|
## Supervision (systemd user unit)
|
||||||
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
Create `~/.config/systemd/user/clawdbot-gateway.service`:
|
||||||
@@ -217,6 +219,7 @@ sudo systemctl enable --now clawdbot-gateway.service
|
|||||||
- `clawdbot gateway send --to <num> --message "hi" [--media-url ...]` — send via Gateway (idempotent).
|
- `clawdbot gateway send --to <num> --message "hi" [--media-url ...]` — send via Gateway (idempotent).
|
||||||
- `clawdbot gateway agent --message "hi" [--to ...]` — run an agent turn (waits for final by default).
|
- `clawdbot gateway agent --message "hi" [--to ...]` — run an agent turn (waits for final by default).
|
||||||
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
|
||||||
|
- `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd/schtasks).
|
||||||
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
|
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
|
||||||
|
|
||||||
## Migration guidance
|
## Migration guidance
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ lsof -nP -i :18789
|
|||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the gateway is supervised by launchd, killing the PID will just respawn it.
|
||||||
|
Stop the supervisor instead:
|
||||||
|
```bash
|
||||||
|
clawdbot gateway stop
|
||||||
|
# Or: launchctl bootout gui/$UID/com.clawdbot.gateway
|
||||||
|
```
|
||||||
|
|
||||||
**Fix 2: Check embedded gateway**
|
**Fix 2: Check embedded gateway**
|
||||||
Ensure the gateway relay was properly bundled. Run `./scripts/package-mac-app.sh` and ensure `bun` is installed.
|
Ensure the gateway relay was properly bundled. Run `./scripts/package-mac-app.sh` and ensure `bun` is installed.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
|
||||||
import { CronAddParamsSchema } from "../../gateway/protocol/schema.js";
|
|
||||||
import {
|
import {
|
||||||
normalizeCronJobCreate,
|
normalizeCronJobCreate,
|
||||||
normalizeCronJobPatch,
|
normalizeCronJobPatch,
|
||||||
} from "../../cron/normalize.js";
|
} from "../../cron/normalize.js";
|
||||||
|
import { CronAddParamsSchema } from "../../gateway/protocol/schema.js";
|
||||||
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
|
|
||||||
const CronJobPatchSchema = Type.Partial(CronAddParamsSchema);
|
const CronJobPatchSchema = Type.Partial(CronAddParamsSchema);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
||||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||||
|
|
||||||
|
|
||||||
async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||||
try {
|
try {
|
||||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const forceFreePortAndWait = vi.fn(async () => ({
|
|||||||
waitedMs: 0,
|
waitedMs: 0,
|
||||||
escalatedToSigkill: false,
|
escalatedToSigkill: false,
|
||||||
}));
|
}));
|
||||||
|
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
const runtimeLogs: string[] = [];
|
const runtimeLogs: string[] = [];
|
||||||
const runtimeErrors: string[] = [];
|
const runtimeErrors: string[] = [];
|
||||||
@@ -74,6 +77,20 @@ vi.mock("./ports.js", () => ({
|
|||||||
forceFreePortAndWait: (port: number) => forceFreePortAndWait(port),
|
forceFreePortAndWait: (port: number) => forceFreePortAndWait(port),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../daemon/service.js", () => ({
|
||||||
|
resolveGatewayService: () => ({
|
||||||
|
label: "LaunchAgent",
|
||||||
|
loadedText: "loaded",
|
||||||
|
notLoadedText: "not loaded",
|
||||||
|
install: vi.fn(),
|
||||||
|
uninstall: vi.fn(),
|
||||||
|
stop: serviceStop,
|
||||||
|
restart: serviceRestart,
|
||||||
|
isLoaded: serviceIsLoaded,
|
||||||
|
readCommand: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("gateway-cli coverage", () => {
|
describe("gateway-cli coverage", () => {
|
||||||
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
|
it("registers call/health/status/send/agent commands and routes to callGateway", async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
@@ -228,6 +245,51 @@ describe("gateway-cli coverage", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("supports gateway stop/restart via service helper", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
serviceStop.mockClear();
|
||||||
|
serviceRestart.mockClear();
|
||||||
|
serviceIsLoaded.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
|
||||||
|
await program.parseAsync(["gateway", "stop"], { from: "user" });
|
||||||
|
await program.parseAsync(["gateway", "restart"], { from: "user" });
|
||||||
|
|
||||||
|
expect(serviceStop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(serviceRestart).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints stop hints on GatewayLockError when service is loaded", async () => {
|
||||||
|
runtimeLogs.length = 0;
|
||||||
|
runtimeErrors.length = 0;
|
||||||
|
serviceIsLoaded.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { GatewayLockError } = await import("../infra/gateway-lock.js");
|
||||||
|
startGatewayServer.mockRejectedValueOnce(
|
||||||
|
new GatewayLockError("another gateway instance is already listening"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerGatewayCli(program);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
program.parseAsync(["gateway", "--allow-unconfigured"], {
|
||||||
|
from: "user",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("__exit__:1");
|
||||||
|
|
||||||
|
expect(startGatewayServer).toHaveBeenCalled();
|
||||||
|
expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:");
|
||||||
|
expect(runtimeErrors.join("\n")).toContain("clawdbot gateway stop");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses env/config port when --port is omitted", async () => {
|
it("uses env/config port when --port is omitted", async () => {
|
||||||
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => {
|
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => {
|
||||||
runtimeLogs.length = 0;
|
runtimeLogs.length = 0;
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
loadConfig,
|
loadConfig,
|
||||||
resolveGatewayPort,
|
resolveGatewayPort,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
|
import {
|
||||||
|
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||||
|
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||||
|
GATEWAY_WINDOWS_TASK_NAME,
|
||||||
|
} from "../daemon/constants.js";
|
||||||
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { startGatewayServer } from "../gateway/server.js";
|
import { startGatewayServer } from "../gateway/server.js";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +51,62 @@ function parsePort(raw: unknown): number | null {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderGatewayServiceStopHints(): string[] {
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
return [
|
||||||
|
"Tip: clawdbot gateway stop",
|
||||||
|
`Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
|
||||||
|
];
|
||||||
|
case "linux":
|
||||||
|
return [
|
||||||
|
"Tip: clawdbot gateway stop",
|
||||||
|
`Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`,
|
||||||
|
];
|
||||||
|
case "win32":
|
||||||
|
return [
|
||||||
|
"Tip: clawdbot gateway stop",
|
||||||
|
`Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`,
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return ["Tip: clawdbot gateway stop"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGatewayServiceStartHints(): string[] {
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
return [
|
||||||
|
`launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`,
|
||||||
|
];
|
||||||
|
case "linux":
|
||||||
|
return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`];
|
||||||
|
case "win32":
|
||||||
|
return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeExplainGatewayServiceStop() {
|
||||||
|
const service = resolveGatewayService();
|
||||||
|
let loaded: boolean | null = null;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch {
|
||||||
|
loaded = null;
|
||||||
|
}
|
||||||
|
if (loaded === false) return;
|
||||||
|
defaultRuntime.error(
|
||||||
|
loaded
|
||||||
|
? `Gateway service appears ${service.loadedText}. Stop it first.`
|
||||||
|
: "Gateway service status unknown; if supervised, stop it first.",
|
||||||
|
);
|
||||||
|
for (const hint of renderGatewayServiceStopHints()) {
|
||||||
|
defaultRuntime.error(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runGatewayLoop(params: {
|
async function runGatewayLoop(params: {
|
||||||
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
||||||
runtime: typeof defaultRuntime;
|
runtime: typeof defaultRuntime;
|
||||||
@@ -285,8 +347,22 @@ export function registerGatewayCli(program: Command) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayLockError) {
|
if (
|
||||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
err instanceof GatewayLockError ||
|
||||||
|
(err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
(err as { name?: string }).name === "GatewayLockError")
|
||||||
|
) {
|
||||||
|
const errMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: typeof err === "object" && err !== null && "message" in err
|
||||||
|
? String((err as { message?: unknown }).message ?? "")
|
||||||
|
: String(err);
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
|
||||||
|
);
|
||||||
|
await maybeExplainGatewayServiceStop();
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -486,8 +562,22 @@ export function registerGatewayCli(program: Command) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayLockError) {
|
if (
|
||||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
err instanceof GatewayLockError ||
|
||||||
|
(err &&
|
||||||
|
typeof err === "object" &&
|
||||||
|
(err as { name?: string }).name === "GatewayLockError")
|
||||||
|
) {
|
||||||
|
const errMessage =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: typeof err === "object" && err !== null && "message" in err
|
||||||
|
? String((err as { message?: unknown }).message ?? "")
|
||||||
|
: String(err);
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
|
||||||
|
);
|
||||||
|
await maybeExplainGatewayServiceStop();
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -635,6 +725,59 @@ export function registerGatewayCli(program: Command) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
gateway
|
||||||
|
.command("stop")
|
||||||
|
.description("Stop the Gateway service (launchd/systemd/schtasks)")
|
||||||
|
.action(async () => {
|
||||||
|
const service = resolveGatewayService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await service.stop({ stdout: process.stdout });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gateway
|
||||||
|
.command("restart")
|
||||||
|
.description("Restart the Gateway service (launchd/systemd/schtasks)")
|
||||||
|
.action(async () => {
|
||||||
|
const service = resolveGatewayService();
|
||||||
|
let loaded = false;
|
||||||
|
try {
|
||||||
|
loaded = await service.isLoaded({ env: process.env });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!loaded) {
|
||||||
|
defaultRuntime.log(`Gateway service ${service.notLoadedText}.`);
|
||||||
|
for (const hint of renderGatewayServiceStartHints()) {
|
||||||
|
defaultRuntime.log(`Start with: ${hint}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await service.restart({ stdout: process.stdout });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Build default deps (keeps parity with other commands; future-proofing).
|
// Build default deps (keeps parity with other commands; future-proofing).
|
||||||
void createDefaultDeps();
|
void createDefaultDeps();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({
|
|||||||
});
|
});
|
||||||
const serviceInstall = vi.fn().mockResolvedValue(undefined);
|
const serviceInstall = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
|
const serviceIsLoaded = vi.fn().mockResolvedValue(false);
|
||||||
|
const serviceStop = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
const serviceRestart = vi.fn().mockResolvedValue(undefined);
|
||||||
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ vi.mock("../daemon/service.js", () => ({
|
|||||||
notLoadedText: "not loaded",
|
notLoadedText: "not loaded",
|
||||||
install: serviceInstall,
|
install: serviceInstall,
|
||||||
uninstall: serviceUninstall,
|
uninstall: serviceUninstall,
|
||||||
|
stop: serviceStop,
|
||||||
restart: serviceRestart,
|
restart: serviceRestart,
|
||||||
isLoaded: serviceIsLoaded,
|
isLoaded: serviceIsLoaded,
|
||||||
readCommand: vi.fn(),
|
readCommand: vi.fn(),
|
||||||
|
|||||||
@@ -660,6 +660,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
note("Gateway daemon not installed.", "Gateway");
|
note("Gateway daemon not installed.", "Gateway");
|
||||||
} else {
|
} else {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
note(
|
||||||
|
`LaunchAgent loaded; stopping requires "clawdbot gateway stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`,
|
||||||
|
"Gateway",
|
||||||
|
);
|
||||||
|
}
|
||||||
const restart = guardCancel(
|
const restart = guardCancel(
|
||||||
await confirm({
|
await confirm({
|
||||||
message: "Restart gateway daemon now?",
|
message: "Restart gateway daemon now?",
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ const UI_FILES = [
|
|||||||
"ui/src/ui/views/cron.ts",
|
"ui/src/ui/views/cron.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
const SWIFT_FILES = [
|
const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"];
|
||||||
"apps/macos/Sources/Clawdbot/GatewayConnection.swift",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("cron protocol conformance", () => {
|
describe("cron protocol conformance", () => {
|
||||||
it("ui + swift include all cron channels from gateway schema", async () => {
|
it("ui + swift include all cron channels from gateway schema", async () => {
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ export function normalizeCronJobInput(
|
|||||||
if (options.applyDefaults) {
|
if (options.applyDefaults) {
|
||||||
if (!next.wakeMode) next.wakeMode = "next-heartbeat";
|
if (!next.wakeMode) next.wakeMode = "next-heartbeat";
|
||||||
if (!next.sessionTarget && isRecord(next.payload)) {
|
if (!next.sessionTarget && isRecord(next.payload)) {
|
||||||
const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
|
const kind =
|
||||||
|
typeof next.payload.kind === "string" ? next.payload.kind : "";
|
||||||
if (kind === "systemEvent") next.sessionTarget = "main";
|
if (kind === "systemEvent") next.sessionTarget = "main";
|
||||||
if (kind === "agentTurn") next.sessionTarget = "isolated";
|
if (kind === "agentTurn") next.sessionTarget = "isolated";
|
||||||
}
|
}
|
||||||
@@ -73,16 +74,18 @@ export function normalizeCronJobCreate(
|
|||||||
raw: unknown,
|
raw: unknown,
|
||||||
options?: NormalizeOptions,
|
options?: NormalizeOptions,
|
||||||
): CronJobCreate | null {
|
): CronJobCreate | null {
|
||||||
return normalizeCronJobInput(raw, { applyDefaults: true, ...options }) as
|
return normalizeCronJobInput(raw, {
|
||||||
| CronJobCreate
|
applyDefaults: true,
|
||||||
| null;
|
...options,
|
||||||
|
}) as CronJobCreate | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCronJobPatch(
|
export function normalizeCronJobPatch(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
options?: NormalizeOptions,
|
options?: NormalizeOptions,
|
||||||
): CronJobPatch | null {
|
): CronJobPatch | null {
|
||||||
return normalizeCronJobInput(raw, { applyDefaults: false, ...options }) as
|
return normalizeCronJobInput(raw, {
|
||||||
| CronJobPatch
|
applyDefaults: false,
|
||||||
| null;
|
...options,
|
||||||
|
}) as CronJobPatch | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,35 @@ export async function uninstallLaunchAgent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLaunchctlNotLoaded(res: {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number;
|
||||||
|
}): boolean {
|
||||||
|
const detail = `${res.stderr || res.stdout}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
detail.includes("no such process") ||
|
||||||
|
detail.includes("could not find service") ||
|
||||||
|
detail.includes("not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopLaunchAgent({
|
||||||
|
stdout,
|
||||||
|
}: {
|
||||||
|
stdout: NodeJS.WritableStream;
|
||||||
|
}): Promise<void> {
|
||||||
|
const domain = resolveGuiDomain();
|
||||||
|
const label = GATEWAY_LAUNCH_AGENT_LABEL;
|
||||||
|
const res = await execLaunchctl(["bootout", `${domain}/${label}`]);
|
||||||
|
if (res.code !== 0 && !isLaunchctlNotLoaded(res)) {
|
||||||
|
throw new Error(
|
||||||
|
`launchctl bootout failed: ${res.stderr || res.stdout}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stdout.write(`Stopped LaunchAgent: ${domain}/${label}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function installLaunchAgent({
|
export async function installLaunchAgent({
|
||||||
env,
|
env,
|
||||||
stdout,
|
stdout,
|
||||||
|
|||||||
@@ -233,6 +233,28 @@ export async function uninstallScheduledTask({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskNotRunning(res: {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number;
|
||||||
|
}): boolean {
|
||||||
|
const detail = `${res.stderr || res.stdout}`.toLowerCase();
|
||||||
|
return detail.includes("not running");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopScheduledTask({
|
||||||
|
stdout,
|
||||||
|
}: {
|
||||||
|
stdout: NodeJS.WritableStream;
|
||||||
|
}): Promise<void> {
|
||||||
|
await assertSchtasksAvailable();
|
||||||
|
const res = await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
|
||||||
|
if (res.code !== 0 && !isTaskNotRunning(res)) {
|
||||||
|
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
|
||||||
|
}
|
||||||
|
stdout.write(`Stopped Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function restartScheduledTask({
|
export async function restartScheduledTask({
|
||||||
stdout,
|
stdout,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
isLaunchAgentLoaded,
|
isLaunchAgentLoaded,
|
||||||
readLaunchAgentProgramArguments,
|
readLaunchAgentProgramArguments,
|
||||||
restartLaunchAgent,
|
restartLaunchAgent,
|
||||||
|
stopLaunchAgent,
|
||||||
uninstallLaunchAgent,
|
uninstallLaunchAgent,
|
||||||
} from "./launchd.js";
|
} from "./launchd.js";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
isScheduledTaskInstalled,
|
isScheduledTaskInstalled,
|
||||||
readScheduledTaskCommand,
|
readScheduledTaskCommand,
|
||||||
restartScheduledTask,
|
restartScheduledTask,
|
||||||
|
stopScheduledTask,
|
||||||
uninstallScheduledTask,
|
uninstallScheduledTask,
|
||||||
} from "./schtasks.js";
|
} from "./schtasks.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
isSystemdServiceEnabled,
|
isSystemdServiceEnabled,
|
||||||
readSystemdServiceExecStart,
|
readSystemdServiceExecStart,
|
||||||
restartSystemdService,
|
restartSystemdService,
|
||||||
|
stopSystemdService,
|
||||||
uninstallSystemdService,
|
uninstallSystemdService,
|
||||||
} from "./systemd.js";
|
} from "./systemd.js";
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ export type GatewayService = {
|
|||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
stdout: NodeJS.WritableStream;
|
stdout: NodeJS.WritableStream;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
stop: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
|
||||||
restart: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
|
restart: (args: { stdout: NodeJS.WritableStream }) => Promise<void>;
|
||||||
isLoaded: (args: {
|
isLoaded: (args: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
@@ -59,6 +63,9 @@ export function resolveGatewayService(): GatewayService {
|
|||||||
uninstall: async (args) => {
|
uninstall: async (args) => {
|
||||||
await uninstallLaunchAgent(args);
|
await uninstallLaunchAgent(args);
|
||||||
},
|
},
|
||||||
|
stop: async (args) => {
|
||||||
|
await stopLaunchAgent(args);
|
||||||
|
},
|
||||||
restart: async (args) => {
|
restart: async (args) => {
|
||||||
await restartLaunchAgent(args);
|
await restartLaunchAgent(args);
|
||||||
},
|
},
|
||||||
@@ -78,6 +85,9 @@ export function resolveGatewayService(): GatewayService {
|
|||||||
uninstall: async (args) => {
|
uninstall: async (args) => {
|
||||||
await uninstallSystemdService(args);
|
await uninstallSystemdService(args);
|
||||||
},
|
},
|
||||||
|
stop: async (args) => {
|
||||||
|
await stopSystemdService(args);
|
||||||
|
},
|
||||||
restart: async (args) => {
|
restart: async (args) => {
|
||||||
await restartSystemdService(args);
|
await restartSystemdService(args);
|
||||||
},
|
},
|
||||||
@@ -97,6 +107,9 @@ export function resolveGatewayService(): GatewayService {
|
|||||||
uninstall: async (args) => {
|
uninstall: async (args) => {
|
||||||
await uninstallScheduledTask(args);
|
await uninstallScheduledTask(args);
|
||||||
},
|
},
|
||||||
|
stop: async (args) => {
|
||||||
|
await stopScheduledTask(args);
|
||||||
|
},
|
||||||
restart: async (args) => {
|
restart: async (args) => {
|
||||||
await restartScheduledTask(args);
|
await restartScheduledTask(args);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -331,6 +331,22 @@ export async function uninstallSystemdService({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function stopSystemdService({
|
||||||
|
stdout,
|
||||||
|
}: {
|
||||||
|
stdout: NodeJS.WritableStream;
|
||||||
|
}): Promise<void> {
|
||||||
|
await assertSystemdAvailable();
|
||||||
|
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
|
||||||
|
const res = await execSystemctl(["--user", "stop", unitName]);
|
||||||
|
if (res.code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`systemctl stop failed: ${res.stderr || res.stdout}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stdout.write(`Stopped systemd service: ${unitName}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function restartSystemdService({
|
export async function restartSystemdService({
|
||||||
stdout,
|
stdout,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
normalizeCronJobCreate,
|
||||||
|
normalizeCronJobPatch,
|
||||||
|
} from "../../cron/normalize.js";
|
||||||
import {
|
import {
|
||||||
readCronRunLogEntries,
|
readCronRunLogEntries,
|
||||||
resolveCronRunLogPath,
|
resolveCronRunLogPath,
|
||||||
@@ -17,10 +21,6 @@ import {
|
|||||||
validateWakeParams,
|
validateWakeParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
import {
|
|
||||||
normalizeCronJobCreate,
|
|
||||||
normalizeCronJobPatch,
|
|
||||||
} from "../../cron/normalize.js";
|
|
||||||
|
|
||||||
export const cronHandlers: GatewayRequestHandlers = {
|
export const cronHandlers: GatewayRequestHandlers = {
|
||||||
wake: ({ params, respond, context }) => {
|
wake: ({ params, respond, context }) => {
|
||||||
@@ -88,9 +88,7 @@ export const cronHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await context.cron.add(
|
const job = await context.cron.add(normalized as unknown as CronJobCreate);
|
||||||
normalized as unknown as CronJobCreate,
|
|
||||||
);
|
|
||||||
respond(true, job, undefined);
|
respond(true, job, undefined);
|
||||||
},
|
},
|
||||||
"cron.update": async ({ params, respond, context }) => {
|
"cron.update": async ({ params, respond, context }) => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
resolveGatewayPort,
|
resolveGatewayPort,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
|
import type { AgentModelListConfig } from "../config/types.js";
|
||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
@@ -68,7 +69,6 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, sleep } from "../utils.js";
|
import { resolveUserPath, sleep } from "../utils.js";
|
||||||
import type { WizardPrompter } from "./prompts.js";
|
import type { WizardPrompter } from "./prompts.js";
|
||||||
import type { AgentModelListConfig } from "../config/types.js";
|
|
||||||
|
|
||||||
const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2";
|
const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user