Heartbeat: add relay helper and fix CLI tests
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
### Changes
|
### Changes
|
||||||
- Web relay now supports configurable command heartbeats (`inbound.reply.heartbeatMinutes`, default 30m) that ping Claude with a `HEARTBEAT_OK` sentinel; outbound messages are skipped when the token is returned, and normal/verbose logs record each heartbeat tick.
|
- Web relay now supports configurable command heartbeats (`inbound.reply.heartbeatMinutes`, default 30m) that ping Claude with a `HEARTBEAT_OK` sentinel; outbound messages are skipped when the token is returned, and normal/verbose logs record each heartbeat tick.
|
||||||
- New `warelay heartbeat` CLI triggers a one-off heartbeat (web provider, auto-detects logged-in session; optional `--to` override). Relay gains `--heartbeat-now` to fire an immediate heartbeat on startup.
|
- New `warelay heartbeat` CLI triggers a one-off heartbeat (web provider, auto-detects logged-in session; optional `--to` override). Relay gains `--heartbeat-now` to fire an immediate heartbeat on startup.
|
||||||
- Added `warelay relay:tmux:heartbeat` helper to start relay in tmux and emit a startup heartbeat automatically.
|
- Added `warelay relay:heartbeat` (no tmux) and `warelay relay:tmux:heartbeat` helpers to start relay with an immediate startup heartbeat.
|
||||||
- Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle.
|
- Heartbeat session handling now supports `inbound.reply.session.heartbeatIdleMinutes` and does not refresh `updatedAt` on skipped heartbeats, so sessions still expire on idle.
|
||||||
|
|
||||||
## 1.1.0 — 2025-11-26
|
## 1.1.0 — 2025-11-26
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ Install from npm (global): `npm install -g warelay` (Node 22+). Then choose **on
|
|||||||
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
| `warelay relay` | Auto-reply loop (poll Twilio or listen on Web) | `--provider <auto\|twilio\|web>` `--interval <sec>` `--lookback <min>` `--verbose` |
|
||||||
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
| `warelay status` | Show recent sent/received messages | `--limit <n>` `--lookback <min>` `--json` `--verbose` |
|
||||||
| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--verbose` |
|
| `warelay heartbeat` | Trigger one heartbeat poll (web) | `--provider <auto\|web>` `--to <e164?>` `--verbose` |
|
||||||
|
| `warelay relay:heartbeat` | Run relay with an immediate heartbeat (no tmux) | `--provider <auto\|web>` `--verbose` |
|
||||||
| `warelay relay:tmux:heartbeat` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
| `warelay relay:tmux:heartbeat` | Start relay in tmux and fire a heartbeat on start (web) | _no flags_ |
|
||||||
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
| `warelay webhook` | Run inbound webhook (`ingress=tailscale` updates Twilio; `none` is local-only) | `--ingress tailscale\|none` `--port <port>` `--path <path>` `--reply <text>` `--verbose` `--yes` `--dry-run` |
|
||||||
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
| `warelay login` | Link personal WhatsApp Web via QR | `--verbose` |
|
||||||
@@ -124,7 +125,7 @@ Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business a
|
|||||||
- When `heartbeatMinutes` is set (default 30 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
- When `heartbeatMinutes` is set (default 30 for `mode: "command"`), the relay periodically runs your command/Claude session with a heartbeat prompt.
|
||||||
- If Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
- If Claude replies exactly `HEARTBEAT_OK`, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran.
|
||||||
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
|
- Override session freshness for heartbeats with `session.heartbeatIdleMinutes` (defaults to `session.idleMinutes`). Heartbeat skips do **not** bump `updatedAt`, so sessions still expire normally.
|
||||||
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `--heartbeat-now` to fire once at relay start.
|
- Trigger one manually with `warelay heartbeat` (web provider only, `--verbose` prints session info). Use `warelay relay:heartbeat` for a full relay run with an immediate heartbeat, or `--heartbeat-now` on `relay`/`relay:tmux:heartbeat`.
|
||||||
|
|
||||||
### Logging (optional)
|
### Logging (optional)
|
||||||
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
- File logs are written to `/tmp/warelay/warelay.log` by default. Levels: `silent | fatal | error | warn | info | debug | trace` (CLI `--verbose` forces `debug`). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing.
|
||||||
|
|||||||
@@ -35,4 +35,7 @@ Goal: add a simple heartbeat poll for command-based auto-replies (Claude-driven)
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule.
|
- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule.
|
||||||
- Expose a CLI trigger: `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override). Relay supports `--heartbeat-now` to fire once at startup.
|
- Expose CLI triggers:
|
||||||
|
- `warelay heartbeat` (web provider, defaults to first `allowFrom`; optional `--to` override)
|
||||||
|
- `warelay relay:heartbeat` to run the relay loop with an immediate heartbeat (no tmux)
|
||||||
|
- Relay supports `--heartbeat-now` to fire once at startup (including `relay:tmux:heartbeat`).
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ describe("cli program", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("runs relay tmux attach command", async () => {
|
it("runs relay tmux attach command", async () => {
|
||||||
|
const originalIsTTY = process.stdout.isTTY;
|
||||||
|
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||||
|
true;
|
||||||
|
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
await program.parseAsync(["relay:tmux:attach"], { from: "user" });
|
||||||
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
expect(spawnRelayTmux).toHaveBeenCalledWith(
|
||||||
@@ -87,5 +91,29 @@ describe("cli program", () => {
|
|||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
(process.stdout as typeof process.stdout & { isTTY?: boolean }).isTTY =
|
||||||
|
originalIsTTY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs relay heartbeat command", async () => {
|
||||||
|
pickProvider.mockResolvedValue("web");
|
||||||
|
monitorWebProvider.mockResolvedValue(undefined);
|
||||||
|
const originalExit = runtime.exit;
|
||||||
|
runtime.exit = vi.fn();
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(["relay:heartbeat"], { from: "user" });
|
||||||
|
expect(logWebSelfId).toHaveBeenCalled();
|
||||||
|
expect(monitorWebProvider).toHaveBeenCalledWith(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
runtime,
|
||||||
|
undefined,
|
||||||
|
{ replyHeartbeatNow: true },
|
||||||
|
);
|
||||||
|
expect(runtime.exit).not.toHaveBeenCalled();
|
||||||
|
runtime.exit = originalExit;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ Examples:
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("heartbeat")
|
.command("heartbeat")
|
||||||
.description("Trigger a heartbeat poll once (web provider)")
|
.description("Trigger a heartbeat poll once (web provider, no tmux)")
|
||||||
.option("--provider <provider>", "auto | web", "auto")
|
.option("--provider <provider>", "auto | web", "auto")
|
||||||
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
.option("--to <number>", "Override target E.164; defaults to allowFrom[0]")
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
@@ -397,6 +397,62 @@ Examples:
|
|||||||
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
await monitorTwilio(intervalSeconds, lookbackMinutes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("relay:heartbeat")
|
||||||
|
.description(
|
||||||
|
"Run relay with an immediate heartbeat (no tmux); requires web provider",
|
||||||
|
)
|
||||||
|
.option("--provider <provider>", "auto | web", "auto")
|
||||||
|
.option("--verbose", "Verbose logging", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
setVerbose(Boolean(opts.verbose));
|
||||||
|
const providerPref = String(opts.provider ?? "auto");
|
||||||
|
if (!["auto", "web"].includes(providerPref)) {
|
||||||
|
defaultRuntime.error("--provider must be auto or web");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = await pickProvider(providerPref as "auto" | "web");
|
||||||
|
if (provider !== "web") {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
"Heartbeat relay is only supported for the web provider. Link with `warelay login --verbose`.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logWebSelfId(defaultRuntime, true);
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const effectiveHeartbeat = resolveHeartbeatSeconds(cfg, undefined);
|
||||||
|
const effectivePolicy = resolveReconnectPolicy(cfg, undefined);
|
||||||
|
defaultRuntime.log(
|
||||||
|
info(
|
||||||
|
`Web relay health: heartbeat ${effectiveHeartbeat}s, retries ${effectivePolicy.maxAttempts || "∞"}, backoff ${effectivePolicy.initialMs}→${effectivePolicy.maxMs}ms x${effectivePolicy.factor} (jitter ${Math.round(effectivePolicy.jitter * 100)}%)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await monitorWebProvider(
|
||||||
|
Boolean(opts.verbose),
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
defaultRuntime,
|
||||||
|
undefined,
|
||||||
|
{ replyHeartbeatNow: true },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
`Web relay failed: ${String(err)}. Re-link with 'warelay login --provider web'.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show recent WhatsApp messages (sent and received)")
|
.description("Show recent WhatsApp messages (sent and received)")
|
||||||
@@ -481,13 +537,16 @@ Examples:
|
|||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
|
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||||
const session = await spawnRelayTmux(
|
const session = await spawnRelayTmux(
|
||||||
"pnpm warelay relay --verbose",
|
"pnpm warelay relay --verbose",
|
||||||
true,
|
shouldAttach,
|
||||||
);
|
);
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info(
|
info(
|
||||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`,
|
shouldAttach
|
||||||
|
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose")`
|
||||||
|
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose"); attach manually with "tmux attach -t ${session}"`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -505,6 +564,15 @@ Examples:
|
|||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!process.stdout.isTTY) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
"Cannot attach: stdout is not a TTY. Run this in a terminal or use 'tmux attach -t warelay-relay' manually.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
await spawnRelayTmux("pnpm warelay relay --verbose", true, false);
|
||||||
defaultRuntime.log(info("Attached to warelay-relay session."));
|
defaultRuntime.log(info("Attached to warelay-relay session."));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -522,13 +590,16 @@ Examples:
|
|||||||
)
|
)
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
try {
|
try {
|
||||||
|
const shouldAttach = Boolean(process.stdout.isTTY);
|
||||||
const session = await spawnRelayTmux(
|
const session = await spawnRelayTmux(
|
||||||
"pnpm warelay relay --verbose --heartbeat-now",
|
"pnpm warelay relay --verbose --heartbeat-now",
|
||||||
true,
|
shouldAttach,
|
||||||
);
|
);
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
info(
|
info(
|
||||||
`tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`,
|
shouldAttach
|
||||||
|
? `tmux session started and attached: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now")`
|
||||||
|
: `tmux session started: ${session} (pane running "pnpm warelay relay --verbose --heartbeat-now"); attach manually with "tmux attach -t ${session}"`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import sharp from "sharp";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { WarelayConfig } from "../config/config.js";
|
import type { WarelayConfig } from "../config/config.js";
|
||||||
|
import { resolveStorePath } from "../config/sessions.js";
|
||||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||||
import {
|
import {
|
||||||
HEARTBEAT_TOKEN,
|
HEARTBEAT_TOKEN,
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
resetLoadConfigMock,
|
resetLoadConfigMock,
|
||||||
setLoadConfigMock,
|
setLoadConfigMock,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
import { resolveStorePath } from "../config/sessions.js";
|
|
||||||
|
|
||||||
describe("heartbeat helpers", () => {
|
describe("heartbeat helpers", () => {
|
||||||
it("strips heartbeat token and skips when only token", () => {
|
it("strips heartbeat token and skips when only token", () => {
|
||||||
@@ -137,7 +137,7 @@ describe("runWebHeartbeatOnce", () => {
|
|||||||
sender,
|
sender,
|
||||||
replyResolver: resolver,
|
replyResolver: resolver,
|
||||||
});
|
});
|
||||||
expect(sender).toHaveBeenCalledWith("+1333", "ALERT", { verbose: false });
|
expect(sender).toHaveBeenCalledWith("+1999", "ALERT", { verbose: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not refresh updatedAt when heartbeat is skipped", async () => {
|
it("does not refresh updatedAt when heartbeat is skipped", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user