From be374601d8a5f0b248e2337e82b5ae57e5ebb312 Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:34:13 +0200 Subject: [PATCH] feat(clients): add --manual-refresh and scheduled-refresh toggle (#97) `routstrd clients` only exposed list/delete/add, so operators had no way to refresh client integrations on demand or to stop the daemon from rewriting their client configs every 21 minutes. - clients --manual-refresh: refresh routstr21 models from Nostr and re-run every registered client integration now. The old `routstrd refresh` body moves into a shared refreshModelsAndClientsAction() so both commands stay in sync. - clients --disable-automatic-refresh / --enable-automatic-refresh: toggle the daemon's scheduled refresh job via a new POST /settings/auto-refresh endpoint, so the toggle also works against a remote daemon where the config lives on the host. Persisted as autoRefresh.enabled in config.json. - The daemon refresh job now re-reads autoRefresh on every tick (like the NWC auto-refill getter), so toggling takes effect without a restart. While disabled it polls once a minute so re-enabling applies promptly. - Startup still fetches models (the proxy needs them) but skips the client integration pass when the job is disabled, so a restart cannot overwrite hand-edited client configs. Adds tests/daemon/auto-refresh.* covering the endpoint contract and the persisted flag. Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com> --- README.md | 35 +++++++- SKILL.md | 18 +++- src/cli.ts | 75 ++++++++++++----- src/daemon/http/index.ts | 21 +++++ src/daemon/index.ts | 116 ++++++++++++++++++++------ src/utils/clients.ts | 75 ++++++++++++++++- src/utils/config.ts | 14 ++++ tests/daemon/auto-refresh.scenario.ts | 102 ++++++++++++++++++++++ tests/daemon/auto-refresh.test.ts | 35 ++++++++ 9 files changed, 438 insertions(+), 53 deletions(-) create mode 100644 tests/daemon/auto-refresh.scenario.ts create mode 100644 tests/daemon/auto-refresh.test.ts diff --git a/README.md b/README.md index a9f0ac7..85a08c0 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,17 @@ Test connection: routstrd ping ``` +Refresh models and client integrations on demand: +```sh +routstrd clients --manual-refresh # same as `routstrd refresh` +``` + +Turn the daemon's scheduled refresh on or off (no restart needed): +```sh +routstrd clients --disable-automatic-refresh +routstrd clients --enable-automatic-refresh +``` + Stop the daemon: ```sh routstrd stop @@ -139,6 +150,20 @@ The daemon exposes an HTTP server (default port 8008) with the following endpoin GET /health ``` +#### Automatic Refresh Settings +``` +POST /settings/auto-refresh +``` + +Request body: +```json +{ "enabled": false } +``` + +Enables or disables the scheduled refresh job. Persisted to the daemon's +`config.json` as `autoRefresh.enabled` and picked up on the next tick, so no +daemon restart is required. + #### Route Request ``` POST / @@ -186,10 +211,18 @@ Configuration is stored in `~/.routstrd/config.json`: "port": 8008, "host": "127.0.0.1", "provider": null, - "cocodPath": null + "cocodPath": null, + "autoRefresh": { "enabled": true } } ``` +`autoRefresh.enabled` (default `true`) controls the daemon's scheduled refresh +job, which re-fetches Nostr events, routstr21 models, and client integrations +every 21 minutes. Set it to `false` (or run +`routstrd clients --disable-automatic-refresh`) to turn the schedule off and +refresh manually with `routstrd clients --manual-refresh`. `autoRefresh.intervalMs` +overrides the 21-minute interval. + ### Environment Variables - `ROUTSTRD_DIR` - Config directory (default: `~/.routstrd`) diff --git a/SKILL.md b/SKILL.md index 4d5e18c..3d0a0b8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -107,6 +107,20 @@ routstrd providers enable 0 2 5 List and manage API clients (subcommand required). +| Option | Description | +|--------|-------------| +| `--manual-refresh` | Refresh routstr21 models and all client integrations now | +| `--disable-automatic-refresh` | Disable the daemon's scheduled refresh job | +| `--enable-automatic-refresh` | Re-enable the daemon's scheduled refresh job | + +The daemon refreshes models and client integrations on a schedule (every 21 minutes by default). Use `--manual-refresh` to do it on demand, and `--disable-automatic-refresh` to stop the scheduled job — the setting is stored in the daemon's `config.json` (`autoRefresh.enabled`) and takes effect without a restart. + +```sh +routstrd clients --manual-refresh # refresh models + integrations now +routstrd clients --disable-automatic-refresh # no scheduled refresh +routstrd clients --enable-automatic-refresh # scheduled refresh back on +``` + #### `routstrd clients list` List all registered clients with their ID, name, API key, and creation date. @@ -157,7 +171,7 @@ routstrd remote https://your-remote-daemon.com ### `routstrd refresh` -Refresh routstr21 models from Nostr and re-run integrations for all registered clients. +Refresh routstr21 models from Nostr and re-run integrations for all registered clients. Equivalent to `routstrd clients --manual-refresh`. | Field | Type | Default | Description | |-------|------|---------|-------------| @@ -167,6 +181,7 @@ Refresh routstr21 models from Nostr and re-run integrations for all registered c | `nsec` | string\|null | null | Nostr secret key for NIP-98 auth | | `cocodPath` | string\|null | null | Custom path to cocod executable | | `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | +| `autoRefresh` | object | `{ enabled: true }` | Scheduled refresh job settings (`enabled`, `intervalMs`) | | Variable | Default | Description | |----------|---------|-------------| @@ -293,6 +308,7 @@ Config file: `~/.routstrd/config.json` | `provider` | string\|null | null | Default provider URL | | `cocodPath` | string\|null | null | Custom path to cocod executable | | `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | +| `autoRefresh` | object | `{ enabled: true }` | Scheduled refresh job settings (`enabled`, `intervalMs`) | ### Environment Variables diff --git a/src/cli.ts b/src/cli.ts index e37fc41..196d01a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,8 @@ import { listClientsAction, deleteClientAction, addClientAction, + refreshModelsAndClientsAction, + setAutomaticRefreshAction, } from "./utils/clients"; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { execSync } from "child_process"; @@ -28,7 +30,7 @@ import { type RoutstrdConfig, } from "./utils/config"; import { COCO_LOGS_DIR, logger } from "./utils/logger"; -import { setupIntegration, runIntegrationsForClients, type IntegrationKey } from "./integrations"; +import { setupIntegration, type IntegrationKey } from "./integrations"; import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, @@ -48,7 +50,6 @@ import { walletDir as defaultWalletDir, walletPidPath, } from "./daemon/wallet/paths"; -import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; @@ -858,26 +859,7 @@ program .description("Refresh routstr21 models and client integrations") .action(async () => { await ensureDaemonRunning(); - const config = await loadConfig(); - - // Refresh models via daemon API - console.log("Refreshing routstr21 models..."); - const result = await callDaemon("/v1/models?refresh=true"); - if (result.error) { - console.log(`Model refresh failed: ${result.error}`); - process.exit(1); - } - console.log("Models refreshed."); - - // Refresh integrations for all clients - const clients = await getClientsList(); - if (clients.length > 0) { - console.log(`Refreshing ${clients.length} client integration(s)...`); - await runIntegrationsForClients(clients, config); - console.log("Client integrations refreshed."); - } else { - console.log("No clients to refresh."); - } + await refreshModelsAndClientsAction(); }); // Models - list routstr21 models @@ -1256,7 +1238,54 @@ providersCmd // Clients - list and manage clients const clientsCmd = program .command("clients") - .description("List and manage clients"); + .description("List and manage clients") + .option( + "--manual-refresh", + "Refresh routstr21 models and all client integrations now", + false, + ) + .option( + "--disable-automatic-refresh", + "Disable the daemon's scheduled refresh job", + false, + ) + .option( + "--enable-automatic-refresh", + "Re-enable the daemon's scheduled refresh job", + false, + ) + .action( + async (options: { + manualRefresh: boolean; + disableAutomaticRefresh: boolean; + enableAutomaticRefresh: boolean; + }) => { + if (options.disableAutomaticRefresh && options.enableAutomaticRefresh) { + console.error( + "error: --disable-automatic-refresh and --enable-automatic-refresh are mutually exclusive.", + ); + process.exit(1); + } + + if ( + !options.manualRefresh && + !options.disableAutomaticRefresh && + !options.enableAutomaticRefresh + ) { + clientsCmd.help({ error: true }); + return; + } + + if (options.manualRefresh) { + await ensureDaemonRunning(); + await refreshModelsAndClientsAction(); + } + + if (options.disableAutomaticRefresh || options.enableAutomaticRefresh) { + await setAutomaticRefreshAction(options.enableAutomaticRefresh); + } + }, + ); clientsCmd .command("list") diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 505b6c8..d6949c3 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -785,6 +785,27 @@ export function createDaemonRequestHandler(deps: { return; } + // Scheduled refresh job (Nostr events, models, client integrations). + // The job re-reads this on every tick, so no daemon restart is needed. + if (req.method === "POST" && url.pathname === "/settings/auto-refresh") { + await respond(res, async () => { + const body = await readJsonBody(req); + const enabled = body.enabled === true || body.enabled === "true"; + + const config = await loadDaemonConfig(); + config.autoRefresh = { ...config.autoRefresh, enabled }; + saveDaemonConfig(config); + + return { + output: { + message: `Automatic refresh ${enabled ? "enabled" : "disabled"}.`, + autoRefresh: config.autoRefresh, + }, + }; + }); + return; + } + if (req.method === "GET" && url.pathname === "/models") { try { const forceRefresh = diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 5d7dac0..81b77c7 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -235,39 +235,94 @@ async function main(): Promise { // Ignore } - const REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins + const DEFAULT_REFRESH_INTERVAL_MS = 21 * 60 * 1000; // 21 mins + // While the job is disabled we keep a light poll running so `clients + // --enable-automatic-refresh` takes effect without a daemon restart. + const DISABLED_REFRESH_POLL_MS = 60 * 1000; + + /** + * Read autoRefresh from disk on every tick (like the NWC auto-refill + * getter) so CLI/config changes apply immediately without a restart. + */ + const readAutoRefreshSettings = (): { + enabled: boolean; + intervalMs: number; + } => { + const autoRefresh = loadDaemonConfigSync().autoRefresh; + const intervalMs = + typeof autoRefresh?.intervalMs === "number" && autoRefresh.intervalMs > 0 + ? autoRefresh.intervalMs + : DEFAULT_REFRESH_INTERVAL_MS; + return { enabled: autoRefresh?.enabled !== false, intervalMs }; + }; // Recurring job to refresh routstr21 models - let refreshInterval: ReturnType | null = null; + let refreshTimer: ReturnType | null = null; + let refreshJobActive = false; + let disabledNoticeLogged = false; + + const runScheduledRefresh = async (): Promise => { + logger.log("Running scheduled Nostr event refresh..."); + try { + await modelManager.refreshNostrEvents(); + } catch (error) { + logger.error("Scheduled Nostr event refresh failed:", error); + } + + logger.log("Running scheduled model refresh..."); + try { + await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled"); + } catch (error) { + logger.error("Scheduled model refresh failed:", error); + } + }; + + const scheduleNextRefresh = (): void => { + if (!refreshJobActive) return; + + const { enabled, intervalMs } = readAutoRefreshSettings(); + if (!enabled) { + if (!disabledNoticeLogged) { + logger.log( + "Scheduled refresh job is disabled (autoRefresh.enabled=false). Polling for re-enable every 60s.", + ); + disabledNoticeLogged = true; + } + refreshTimer = setTimeout( + scheduleNextRefresh, + Math.min(intervalMs, DISABLED_REFRESH_POLL_MS), + ); + return; + } + + if (disabledNoticeLogged) { + logger.log("Scheduled refresh job re-enabled."); + disabledNoticeLogged = false; + } + + refreshTimer = setTimeout(() => { + void runScheduledRefresh() + .catch((error) => logger.error("Model refresh interval escaped:", error)) + .finally(() => scheduleNextRefresh()); + }, intervalMs); + }; const startModelRefreshJob = () => { + refreshJobActive = true; + const { enabled, intervalMs } = readAutoRefreshSettings(); logger.log( - `Starting recurring model refresh job (every ${REFRESH_INTERVAL_MS / 1000 / 60 / 60} hours)`, + enabled + ? `Starting recurring model refresh job (every ${Math.round(intervalMs / 60_000)} minutes)` + : "Recurring model refresh job is disabled (autoRefresh.enabled=false).", ); - - refreshInterval = setInterval(() => { - (async () => { - logger.log("Running scheduled Nostr event refresh..."); - try { - await modelManager.refreshNostrEvents(); - } catch (error) { - logger.error("Scheduled Nostr event refresh failed:", error); - } - - logger.log("Running scheduled model refresh..."); - try { - await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Scheduled"); - } catch (error) { - logger.error("Scheduled model refresh failed:", error); - } - })().catch((error) => logger.error("Model refresh interval escaped:", error)); - }, REFRESH_INTERVAL_MS); + scheduleNextRefresh(); }; const stopModelRefreshJob = () => { - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; + refreshJobActive = false; + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; logger.log("Stopped recurring model refresh job."); } }; @@ -381,9 +436,16 @@ async function main(): Promise { startModelRefreshJob(); startRefundJob(); - // Run an immediate refresh to populate models right away - logger.log("Running initial model refresh..."); - await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial"); + // Run an immediate refresh to populate models right away. Client + // integrations are skipped when the scheduled job is disabled, so a + // restart does not overwrite hand-edited client configs. + if (readAutoRefreshSettings().enabled) { + logger.log("Running initial model refresh..."); + await refreshModelsAndIntegrations(getRoutstr21Models, updatedConfig, "Initial"); + } else { + logger.log("Running initial model refresh (client integrations skipped)..."); + await getRoutstr21Models(true); + } }) .catch((error) => { logger.error("Initial model refresh failed:", error); diff --git a/src/utils/clients.ts b/src/utils/clients.ts index 9149367..b7b122d 100644 --- a/src/utils/clients.ts +++ b/src/utils/clients.ts @@ -6,7 +6,11 @@ import { ensureDaemonRunning, } from "./daemon-client"; import { logger } from "./logger"; -import { CLIENT_INTEGRATIONS, CLIENT_CONFIGS } from "../integrations/registry"; +import { + CLIENT_INTEGRATIONS, + CLIENT_CONFIGS, + runIntegrationsForClients, +} from "../integrations/registry"; export interface ClientEntry { clientId: string; @@ -125,6 +129,75 @@ export async function addDaemonClient( return { message: output.message, client: output.client, created: true }; } +/** + * Refresh routstr21 models from Nostr, then re-run every registered client + * integration so local client configs pick up new models and API keys. + * + * Shared by `routstrd refresh` and `routstrd clients --manual-refresh` — the + * same work the daemon performs on its scheduled refresh. + */ +export async function refreshModelsAndClientsAction(): Promise { + const config = await loadConfig(); + + console.log("Refreshing routstr21 models..."); + const result = await callDaemon("/v1/models?refresh=true"); + if (result.error) { + console.log(`Model refresh failed: ${result.error}`); + process.exit(1); + } + console.log("Models refreshed."); + + const clients = await getClientsList(); + if (clients.length === 0) { + console.log("No clients to refresh."); + return; + } + + console.log(`Refreshing ${clients.length} client integration(s)...`); + await runIntegrationsForClients(clients, config); + console.log("Client integrations refreshed."); +} + +/** + * Toggle the daemon's scheduled refresh job. Routed through the daemon so it + * also works against a remote daemon, where the config lives on the host. + */ +export async function setAutomaticRefreshAction( + enabled: boolean, +): Promise { + await ensureDaemonRunning(); + + const result = await callDaemon("/settings/auto-refresh", { + method: "POST", + body: { enabled }, + }); + + if (result.error) { + console.log(result.error); + process.exit(1); + } + + const output = result.output as { message?: string } | undefined; + console.log( + output?.message ?? `Automatic refresh ${enabled ? "enabled" : "disabled"}.`, + ); + + if (enabled) { + console.log( + "The daemon will keep refreshing models and client integrations on a schedule.", + ); + return; + } + + console.log( + "The daemon will stop the scheduled refresh of models and client integrations.", + ); + console.log("Run 'routstrd clients --manual-refresh' to refresh on demand."); + console.log( + "Run 'routstrd clients --enable-automatic-refresh' to turn it back on.", + ); +} + export async function listClientsAction(): Promise { await ensureDaemonRunning(); diff --git a/src/utils/config.ts b/src/utils/config.ts index 59f785c..bc35480 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -20,6 +20,18 @@ export interface NwcAutoRefillConfig { cooldownMs: number; } +/** + * Scheduled background refresh job (Nostr events, routstr21 models, and client + * integrations). Missing values mean "enabled" with the default interval, so + * older config files keep working unchanged. + */ +export interface AutoRefreshConfig { + /** Run the recurring refresh job. Defaults to true. */ + enabled?: boolean; + /** Milliseconds between refreshes. Defaults to 21 minutes. */ + intervalMs?: number; +} + /** NWC configuration section */ export interface NwcConfig { /** NWC mode: "funding_source" = NWC funds the cocod Cashu wallet */ @@ -57,6 +69,8 @@ export interface RoutstrdConfig { relays?: string[]; /** NWC integration configuration */ nwc?: NwcConfig; + /** Scheduled refresh job settings (see AutoRefreshConfig). */ + autoRefresh?: AutoRefreshConfig; /** * Default max_tokens (chat/completions) and max_output_tokens (responses) * injected into proxied requests when the client does not supply one. diff --git a/tests/daemon/auto-refresh.scenario.ts b/tests/daemon/auto-refresh.scenario.ts new file mode 100644 index 0000000..1dbf5a5 --- /dev/null +++ b/tests/daemon/auto-refresh.scenario.ts @@ -0,0 +1,102 @@ +// Scenario runner for auto-refresh.test.ts — executed as a standalone bun +// process with ROUTSTRD_DIR set before module evaluation, because CONFIG_FILE +// is resolved at import time. +import { createServer } from "http"; +import type { AddressInfo } from "net"; + +const { createDaemonRequestHandler } = await import("../../src/daemon/http/index"); +const { loadDaemonConfigSync } = await import("../../src/daemon/config-store"); + +function assert(cond: unknown, msg: string): void { + if (!cond) { + console.error(`ASSERT-FAIL: ${msg}`); + process.exit(1); + } +} + +/** + * Only the settings endpoints are exercised, so every daemon dependency is a + * stub that would throw loudly if the request reached the proxy path. + */ +const stubDeps = { + provider: null, + server: { close() {} }, + store: {}, + walletClient: {}, + walletAdapter: {}, + storageAdapter: {}, + discoveryAdapter: {}, + modelManager: {}, + ensureProvidersBootstrapped: async () => {}, + getRoutstr21Models: async () => [], + getModelProviders: async () => [], + refreshProvidersAndModels: async () => {}, + mode: "apikeys" as const, + maxTokens: 64000, + usageTrackingDriver: {}, + providerManager: {}, + refundClient: {}, +}; + +async function withServer( + run: (port: number) => Promise, +): Promise { + const server = createServer( + createDaemonRequestHandler(stubDeps as never), + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + await run((server.address() as AddressInfo).port); + } finally { + server.close(); + } +} + +const scenario = process.argv[2]; + +switch (scenario) { + case "toggle-endpoint": { + await withServer(async (port) => { + const post = (body: unknown) => + fetch(`http://127.0.0.1:${port}/settings/auto-refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + // Missing config means "enabled" so existing installs are unaffected. + assert( + loadDaemonConfigSync().autoRefresh?.enabled === undefined, + "expected no autoRefresh in a fresh config", + ); + + const disabled = await post({ enabled: false }); + assert(disabled.status === 200, `disable status ${disabled.status}`); + const disabledBody = (await disabled.json()) as { + output?: { autoRefresh?: { enabled?: boolean } }; + }; + assert( + disabledBody.output?.autoRefresh?.enabled === false, + "disable response did not report enabled=false", + ); + assert( + loadDaemonConfigSync().autoRefresh?.enabled === false, + "disable was not persisted to config.json", + ); + + const enabled = await post({ enabled: true }); + assert(enabled.status === 200, `enable status ${enabled.status}`); + assert( + loadDaemonConfigSync().autoRefresh?.enabled === true, + "enable was not persisted to config.json", + ); + }); + break; + } + + default: + console.error(`unknown scenario: ${scenario}`); + process.exit(2); +} + +console.log("SCENARIO-OK"); diff --git a/tests/daemon/auto-refresh.test.ts b/tests/daemon/auto-refresh.test.ts new file mode 100644 index 0000000..a362f22 --- /dev/null +++ b/tests/daemon/auto-refresh.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// The scenario mutates process.env.ROUTSTRD_DIR before the config module is +// loaded, so it runs in an isolated bun subprocess (same approach as +// config-store.perms.test.ts). + +const SCENARIO = join(import.meta.dir, "auto-refresh.scenario.ts"); + +function runScenario(name: string): { code: number; out: string } { + const dir = mkdtempSync(join(tmpdir(), `routstrd-auto-refresh-${name}-`)); + const res = spawnSync("bun", [SCENARIO, name], { + env: { + ...process.env, + NODE_ENV: "test", + ROUTSTRD_DIR: join(dir, "daemon"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const out = `${res.stdout}${res.stderr}`; + rmSync(dir, { recursive: true, force: true }); + return { code: res.status ?? res.exitCode ?? -1, out }; +} + +describe("auto-refresh settings endpoint", () => { + test("persists the scheduled-refresh toggle to the daemon config", () => { + const { code, out } = runScenario("toggle-endpoint"); + expect(out).toContain("SCENARIO-OK"); + expect(code).toBe(0); + }); +});