mirror of
https://github.com/Routstr/routstrd.git
synced 2026-09-14 02:55:07 +00:00
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>
This commit is contained in:
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+52
-23
@@ -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")
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+89
-27
@@ -235,39 +235,94 @@ async function main(): Promise<void> {
|
||||
// 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<typeof setInterval> | null = null;
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshJobActive = false;
|
||||
let disabledNoticeLogged = false;
|
||||
|
||||
const runScheduledRefresh = async (): Promise<void> => {
|
||||
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<void> {
|
||||
|
||||
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);
|
||||
|
||||
+74
-1
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await ensureDaemonRunning();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
const server = createServer(
|
||||
createDaemonRequestHandler(stubDeps as never),
|
||||
);
|
||||
await new Promise<void>((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");
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user