diff --git a/src/constants.mjs b/src/constants.mjs index 31e49cb..a9b2440 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -8,5 +8,7 @@ export const DEFAULT_RELAYS = [ export const DEFAULT_TTL = 7200; export const DEFAULT_NAMESPACE = 'torch'; export const DEFAULT_QUERY_TIMEOUT_MS = 15_000; +export const DEFAULT_PUBLISH_TIMEOUT_MS = 10_000; +export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1; export const VALID_CADENCES = new Set(['daily', 'weekly']); export const KIND_APP_DATA = 30078; diff --git a/src/docs/TORCH.md b/src/docs/TORCH.md index edb0d73..dff6b90 100644 --- a/src/docs/TORCH.md +++ b/src/docs/TORCH.md @@ -32,6 +32,9 @@ node bin/torch-lock.mjs list - `NOSTR_LOCK_RELAYS` - `NOSTR_LOCK_TTL` - `NOSTR_LOCK_QUERY_TIMEOUT_MS` +- `NOSTR_LOCK_PUBLISH_TIMEOUT_MS` +- `NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES` +- `NOSTR_LOCK_RELAY_FALLBACKS` - `NOSTR_LOCK_DAILY_ROSTER` - `NOSTR_LOCK_WEEKLY_ROSTER` - `TORCH_CONFIG_PATH` @@ -50,7 +53,10 @@ Common settings: - `nostrLock.namespace` — namespace prefix used in d-tags and hashtags. - `nostrLock.relays` — relay list for check/lock/list operations. - `nostrLock.ttlSeconds` — default lock TTL. -- `nostrLock.queryTimeoutMs` — relay query timeout. +- `nostrLock.queryTimeoutMs` — relay query timeout (ms, valid range: 100..120000). +- `nostrLock.publishTimeoutMs` — per-relay publish timeout (ms, valid range: 100..120000). +- `nostrLock.minSuccessfulRelayPublishes` — minimum successful publishes required before lock acquisition continues (default: `1`). +- `nostrLock.relayFallbacks` — optional fallback relay URLs used when primary query/publish attempts fail quorum. - `nostrLock.dailyRoster` / `nostrLock.weeklyRoster` — optional per-project roster overrides. - `dashboard.defaultCadenceView` — default dashboard view (`daily`, `weekly`, `all`). - `dashboard.defaultStatusView` — default dashboard status filter (`active`, `all`). @@ -64,6 +70,23 @@ For weekly repository-fit maintenance, TORCH also includes `src/prompts/weekly/r Operational note: scheduler handoff commands are treated as required execution steps. A non-zero exit code (or missing command) is a hard failure: the scheduler writes a `_failed.md` task log, exits immediately, and does not publish `lock:complete` for that run. + +## Lock backend production defaults + +Recommended baseline for production scheduler runs: + +- `nostrLock.relays`: 3+ geographically-diverse primary relays. +- `nostrLock.relayFallbacks`: 2 additional relays not present in primary list. +- `nostrLock.queryTimeoutMs`: `10000` +- `nostrLock.publishTimeoutMs`: `8000` +- `nostrLock.minSuccessfulRelayPublishes`: `2` + +Validation behavior: + +- Relay URLs must be absolute `ws://` or `wss://` URLs. +- Invalid relay URLs or invalid timeout/count ranges are fatal startup errors. +- Lock backend errors include phase (`query:primary`, `query:fallback`, `publish:primary`, `publish:fallback`), relay endpoint, and timeout value used. + ## Scheduler lock reliability reporting Run the lock reliability summary to aggregate recent scheduler outcomes by platform, cadence, backend error category, and relay endpoint: diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index b50e1fc..8326f08 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -1,5 +1,10 @@ import { SimplePool } from 'nostr-tools/pool'; -import { getQueryTimeoutMs } from './torch-config.mjs'; +import { + getQueryTimeoutMs, + getPublishTimeoutMs, + getMinSuccessfulRelayPublishes, + getRelayFallbacks, +} from './torch-config.mjs'; import { KIND_APP_DATA } from './constants.mjs'; function nowUnix() { @@ -42,56 +47,141 @@ function filterActiveLocks(locks) { return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now); } -export async function queryLocks(relays, cadence, dateStr, namespace) { - const pool = new SimplePool(); - const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; - const queryTimeoutMs = getQueryTimeoutMs(); - +function withTimeout(promise, timeoutMs, timeoutMessage) { let timeoutHandle; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + }); +} + +function relayListLabel(relays) { + return relays.join(', '); +} + +export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) { + const { + poolFactory = () => new SimplePool(), + getQueryTimeoutMsFn = getQueryTimeoutMs, + getRelayFallbacksFn = getRelayFallbacks, + errorLogger = console.error, + } = deps; + + const pool = poolFactory(); + const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; + const queryTimeoutMs = getQueryTimeoutMsFn(); + const fallbackRelays = getRelayFallbacksFn(); + + const runQuery = async (relaySet, phase) => { + try { + const events = await withTimeout( + pool.querySync(relaySet, { + kinds: [KIND_APP_DATA], + '#t': [tagFilter], + }), + queryTimeoutMs, + `[${phase}] Relay query timed out after ${queryTimeoutMs}ms (relays: ${relayListLabel(relaySet)})`, + ); + return filterActiveLocks(events.map(parseLockEvent)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `[${phase}] Relay query failed (timeout=${queryTimeoutMs}ms, relays=${relayListLabel(relaySet)}): ${message}`, + { cause: err }, + ); + } + }; try { - const events = await Promise.race([ - pool.querySync(relays, { - kinds: [KIND_APP_DATA], - '#t': [tagFilter], - }), - new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs); - }), - ]); - - return filterActiveLocks(events.map(parseLockEvent)); - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); + try { + return await runQuery(relays, 'query:primary'); + } catch (primaryErr) { + if (!fallbackRelays.length) { + throw primaryErr; + } + errorLogger(`WARN: ${primaryErr.message}`); + errorLogger(`WARN: retrying query with fallback relays (${relayListLabel(fallbackRelays)})`); + return await runQuery(fallbackRelays, 'query:fallback'); } - pool.close(relays); + } finally { + const allRelays = [...new Set([...relays, ...fallbackRelays])]; + pool.close(allRelays); } } -export async function publishLock(relays, event) { - const pool = new SimplePool(); +async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) { + const publishPromises = pool.publish(relays, event); + const settled = await Promise.allSettled( + publishPromises.map((publishPromise, index) => withTimeout( + publishPromise, + publishTimeoutMs, + `[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`, + )), + ); + + return settled.map((result, index) => { + if (result.status === 'fulfilled') { + return { relay: relays[index], success: true, phase }; + } + const reason = result.reason; + const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown'); + return { relay: relays[index], success: false, phase, message }; + }); +} + +export async function publishLock(relays, event, deps = {}) { + const { + poolFactory = () => new SimplePool(), + getPublishTimeoutMsFn = getPublishTimeoutMs, + getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes, + getRelayFallbacksFn = getRelayFallbacks, + } = deps; + + const pool = poolFactory(); + const publishTimeoutMs = getPublishTimeoutMsFn(); + const minSuccesses = getMinSuccessfulRelayPublishesFn(); + const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay)); + + const attempted = new Set(); + const publishResults = []; + + const attemptPhase = async (phaseRelays, phaseName) => { + if (!phaseRelays.length) return; + for (const relay of phaseRelays) { + attempted.add(relay); + } + const phaseResults = await publishToRelays(pool, phaseRelays, event, publishTimeoutMs, phaseName); + publishResults.push(...phaseResults); + }; try { - const publishPromises = pool.publish(relays, event); - const results = await Promise.allSettled(publishPromises); - const successes = results.filter((r) => r.status === 'fulfilled'); + await attemptPhase(relays, 'publish:primary'); - if (successes.length === 0) { - const errors = results.map((r, i) => { - if (r.status === 'rejected') { - const reason = r.reason; - const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown'); - return `${relays[i]}: ${message}`; - } - return `${relays[i]}: unknown`; - }); - throw new Error(`Failed to publish to any relay:\n ${errors.join('\n ')}`); + let successCount = publishResults.filter((result) => result.success).length; + if (successCount < minSuccesses && fallbackRelays.length > 0) { + await attemptPhase(fallbackRelays, 'publish:fallback'); + successCount = publishResults.filter((result) => result.success).length; } - console.error(` Published to ${successes.length}/${relays.length} relays`); + if (successCount < minSuccesses) { + const failureLines = publishResults + .filter((result) => !result.success) + .map((result) => `${result.relay} (${result.phase}): ${result.message}`); + + throw new Error( + `Failed relay publish quorum in publish phase: ${successCount}/${attempted.size} successful ` + + `(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)\n ${failureLines.join('\n ')}`, + ); + } + + console.error( + ` Published to ${successCount}/${attempted.size} relays ` + + `(required=${minSuccesses}, timeout=${publishTimeoutMs}ms)`, + ); return event; } finally { - pool.close(relays); + pool.close([...attempted]); } } diff --git a/src/torch-config.mjs b/src/torch-config.mjs index 82a1929..d7de51f 100644 --- a/src/torch-config.mjs +++ b/src/torch-config.mjs @@ -5,11 +5,52 @@ import { DEFAULT_TTL, DEFAULT_NAMESPACE, DEFAULT_QUERY_TIMEOUT_MS, + DEFAULT_PUBLISH_TIMEOUT_MS, + DEFAULT_MIN_SUCCESSFUL_PUBLISHES, } from './constants.mjs'; const DEFAULT_CONFIG_PATH = 'torch-config.json'; let cachedConfig = null; +const MIN_TIMEOUT_MS = 100; +const MAX_TIMEOUT_MS = 120_000; + +function parsePositiveInteger(value) { + if (!Number.isFinite(value) || value <= 0) return null; + return Math.floor(value); +} + +function parseRelayList(value) { + if (!Array.isArray(value)) return null; + const parsed = value.map((relay) => String(relay).trim()).filter(Boolean); + return parsed.length > 0 ? parsed : null; +} + +function assertValidRelayUrl(relay, sourceLabel) { + let parsed; + try { + parsed = new URL(relay); + } catch { + throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (must be an absolute ws:// or wss:// URL)`); + } + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { + throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (protocol must be ws:// or wss://)`); + } +} + +function assertTimeoutInRange(value, sourceLabel) { + if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) { + throw new Error( + `Invalid ${sourceLabel}: ${value} (must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms)`, + ); + } +} + +function assertPositiveCount(value, sourceLabel) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid ${sourceLabel}: ${value} (must be a positive integer)`); + } +} function normalizeCadence(value, fallback) { const normalized = String(value || '').trim().toLowerCase(); @@ -48,12 +89,13 @@ export function parseTorchConfig(raw, configPath = null) { relays: Array.isArray(nostrLock.relays) ? nostrLock.relays.map((relay) => String(relay).trim()).filter(Boolean) : null, + relayFallbacks: parseRelayList(nostrLock.relayFallbacks), ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0 ? Math.floor(nostrLock.ttlSeconds) : null, - queryTimeoutMs: Number.isFinite(nostrLock.queryTimeoutMs) && nostrLock.queryTimeoutMs > 0 - ? Math.floor(nostrLock.queryTimeoutMs) - : null, + queryTimeoutMs: parsePositiveInteger(nostrLock.queryTimeoutMs), + publishTimeoutMs: parsePositiveInteger(nostrLock.publishTimeoutMs), + minSuccessfulRelayPublishes: parsePositiveInteger(nostrLock.minSuccessfulRelayPublishes), dailyRoster: parseRoster(nostrLock.dailyRoster), weeklyRoster: parseRoster(nostrLock.weeklyRoster), }, @@ -99,21 +141,72 @@ export function loadTorchConfig(fileSystem = fs) { } cachedConfig = parseTorchConfig(raw, configPath); + validateLockBackendConfig(cachedConfig); return cachedConfig; } +function validateLockBackendConfig(config) { + const relays = config.nostrLock.relays || []; + const relayFallbacks = config.nostrLock.relayFallbacks || []; + + for (const relay of relays) { + assertValidRelayUrl(relay, 'nostrLock.relays'); + } + for (const relay of relayFallbacks) { + assertValidRelayUrl(relay, 'nostrLock.relayFallbacks'); + } + + if (config.nostrLock.queryTimeoutMs !== null) { + assertTimeoutInRange(config.nostrLock.queryTimeoutMs, 'nostrLock.queryTimeoutMs'); + } + if (config.nostrLock.publishTimeoutMs !== null) { + assertTimeoutInRange(config.nostrLock.publishTimeoutMs, 'nostrLock.publishTimeoutMs'); + } + if (config.nostrLock.minSuccessfulRelayPublishes !== null) { + assertPositiveCount(config.nostrLock.minSuccessfulRelayPublishes, 'nostrLock.minSuccessfulRelayPublishes'); + } +} + +function parseEnvRelayList(envValue, envName) { + const relays = envValue.split(',').map((r) => r.trim()).filter(Boolean); + for (const relay of relays) { + assertValidRelayUrl(relay, envName); + } + return relays; +} + +function parseEnvInteger(envValue, envName) { + const parsed = parseInt(envValue, 10); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid ${envName}: "${envValue}" (must be an integer)`); + } + return parsed; +} + export function getRelays() { const config = loadTorchConfig(); const envRelays = process.env.NOSTR_LOCK_RELAYS; if (envRelays) { - return envRelays.split(',').map((r) => r.trim()).filter(Boolean); + return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS'); } if (config.nostrLock.relays?.length) { return config.nostrLock.relays; } + for (const relay of DEFAULT_RELAYS) { + assertValidRelayUrl(relay, 'DEFAULT_RELAYS'); + } return DEFAULT_RELAYS; } +export function getRelayFallbacks() { + const config = loadTorchConfig(); + const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS; + if (envRelays) { + return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS'); + } + return config.nostrLock.relayFallbacks || []; +} + export function getNamespace() { const config = loadTorchConfig(); const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim(); @@ -137,8 +230,37 @@ export function getQueryTimeoutMs() { const config = loadTorchConfig(); const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS; if (envValue) { - const parsed = parseInt(envValue, 10); - if (!Number.isNaN(parsed) && parsed > 0) return parsed; + const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_QUERY_TIMEOUT_MS'); + assertTimeoutInRange(parsed, 'NOSTR_LOCK_QUERY_TIMEOUT_MS'); + return parsed; } - return config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS; + const value = config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS; + assertTimeoutInRange(value, 'effective query timeout'); + return value; +} + +export function getPublishTimeoutMs() { + const config = loadTorchConfig(); + const envValue = process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS; + if (envValue) { + const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS'); + assertTimeoutInRange(parsed, 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS'); + return parsed; + } + const value = config.nostrLock.publishTimeoutMs || DEFAULT_PUBLISH_TIMEOUT_MS; + assertTimeoutInRange(value, 'effective publish timeout'); + return value; +} + +export function getMinSuccessfulRelayPublishes() { + const config = loadTorchConfig(); + const envValue = process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES; + if (envValue) { + const parsed = parseEnvInteger(envValue, 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES'); + assertPositiveCount(parsed, 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES'); + return parsed; + } + const value = config.nostrLock.minSuccessfulRelayPublishes || DEFAULT_MIN_SUCCESSFUL_PUBLISHES; + assertPositiveCount(value, 'effective min successful relay publishes'); + return value; } diff --git a/test/lock-ops.test.mjs b/test/lock-ops.test.mjs index 699798f..b79a7e4 100644 --- a/test/lock-ops.test.mjs +++ b/test/lock-ops.test.mjs @@ -1,121 +1,123 @@ -import { test, describe, it } from 'node:test'; +import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { parseLockEvent } from '../src/lib.mjs'; +import { parseLockEvent, publishLock, queryLocks } from '../src/lock-ops.mjs'; -describe('parseLockEvent', () => { - it('should parse a valid lock event correctly', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, // 2023-03-15T13:20:00.000Z - tags: [ - ['d', 'mock-d-tag'], - ['expiration', '1678890000'], // 2023-03-15T14:20:00.000Z - ], - content: JSON.stringify({ - agent: 'test-agent', - cadence: 'daily', - status: 'started', - namespace: 'test-ns', - date: '2023-03-15', - platform: 'test-platform', - }), - }; +describe('lock-ops', () => { + describe('parseLockEvent', () => { + it('parses a valid lock event correctly', () => { + const event = { + id: 'mock-event-id', + pubkey: 'mock-pubkey', + created_at: 1678886400, + tags: [['d', 'mock-d-tag'], ['expiration', '1678890000']], + content: JSON.stringify({ + agent: 'test-agent', + cadence: 'daily', + status: 'started', + date: '2023-03-15', + platform: 'test-platform', + }), + }; - const result = parseLockEvent(event); + const result = parseLockEvent(event); - assert.strictEqual(result.eventId, 'mock-event-id'); - assert.strictEqual(result.pubkey, 'mock-pubkey'); - assert.strictEqual(result.createdAt, 1678886400); - assert.strictEqual(result.createdAtIso, '2023-03-15T13:20:00.000Z'); - assert.strictEqual(result.expiresAt, 1678890000); - assert.strictEqual(result.expiresAtIso, '2023-03-15T14:20:00.000Z'); - assert.strictEqual(result.dTag, 'mock-d-tag'); - assert.strictEqual(result.agent, 'test-agent'); - assert.strictEqual(result.cadence, 'daily'); - assert.strictEqual(result.status, 'started'); - assert.strictEqual(result.date, '2023-03-15'); - assert.strictEqual(result.platform, 'test-platform'); + assert.strictEqual(result.dTag, 'mock-d-tag'); + assert.strictEqual(result.expiresAt, 1678890000); + assert.strictEqual(result.agent, 'test-agent'); + assert.strictEqual(result.platform, 'test-platform'); + }); }); - it('should handle missing d tag', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, - tags: [], - content: '{}', - }; + describe('queryLocks', () => { + it('falls back to fallback relays when primary query fails', async () => { + const mockPool = { + querySync: async (relays) => { + if (relays[0] === 'wss://primary') { + throw new Error('primary unavailable'); + } + return [{ + id: 'id-1', + pubkey: 'pk', + created_at: Math.floor(Date.now() / 1000), + tags: [['d', 't']], + content: JSON.stringify({ agent: 'agent1' }), + }]; + }, + close: () => {}, + }; - const result = parseLockEvent(event); - assert.strictEqual(result.dTag, ''); + const locks = await queryLocks( + ['wss://primary'], + 'daily', + '2026-02-14', + 'torch', + { + poolFactory: () => mockPool, + getQueryTimeoutMsFn: () => 200, + getRelayFallbacksFn: () => ['wss://fallback'], + errorLogger: () => {}, + }, + ); + + assert.strictEqual(locks.length, 1); + assert.strictEqual(locks[0].agent, 'agent1'); + }); }); - it('should handle missing expiration tag', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, - tags: [['d', 'mock-d-tag']], - content: '{}', - }; + describe('publishLock', () => { + it('succeeds with partial primary success when minimum successes is 1', async () => { + const mockPool = { + publish: (relays) => relays.map((relay) => ( + relay.includes('ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline')) + )), + close: () => {}, + }; - const result = parseLockEvent(event); - assert.strictEqual(result.expiresAt, null); - assert.strictEqual(result.expiresAtIso, null); - }); + const event = { id: 'evt-1' }; + const result = await publishLock(['wss://ok-1', 'wss://bad-1'], event, { + poolFactory: () => mockPool, + getPublishTimeoutMsFn: () => 200, + getMinSuccessfulRelayPublishesFn: () => 1, + getRelayFallbacksFn: () => [], + }); - it('should handle malformed JSON content', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, - tags: [], - content: '{ invalid json }', - }; + assert.strictEqual(result, event); + }); - const result = parseLockEvent(event); - assert.strictEqual(result.agent, null); - assert.strictEqual(result.cadence, null); - }); + it('uses fallback relays to satisfy min success quorum', async () => { + const mockPool = { + publish: (relays) => relays.map((relay) => ( + relay.includes('fallback-ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline')) + )), + close: () => {}, + }; - it('should handle non-object JSON content (string)', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, - tags: [], - content: '"just a string"', - }; + const event = { id: 'evt-2' }; + const result = await publishLock(['wss://primary-bad'], event, { + poolFactory: () => mockPool, + getPublishTimeoutMsFn: () => 200, + getMinSuccessfulRelayPublishesFn: () => 1, + getRelayFallbacksFn: () => ['wss://fallback-ok'], + }); - const result = parseLockEvent(event); - assert.strictEqual(result.agent, null); - }); + assert.strictEqual(result, event); + }); - it('should handle non-object JSON content (array)', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, - tags: [], - content: '["item1", "item2"]', - }; + it('fails when successful publishes stay below configured minimum', async () => { + const mockPool = { + publish: (relays) => relays.map(() => Promise.reject(new Error('offline'))), + close: () => {}, + }; - const result = parseLockEvent(event); - assert.strictEqual(result.agent, null); - }); - - it('should handle partial JSON content', () => { - const event = { - id: 'mock-event-id', - pubkey: 'mock-pubkey', - created_at: 1678886400, - tags: [], - content: JSON.stringify({ agent: 'partial-agent' }), - }; - - const result = parseLockEvent(event); - assert.strictEqual(result.agent, 'partial-agent'); - assert.strictEqual(result.cadence, null); + await assert.rejects( + () => publishLock(['wss://primary-bad'], { id: 'evt-3' }, { + poolFactory: () => mockPool, + getPublishTimeoutMsFn: () => 200, + getMinSuccessfulRelayPublishesFn: () => 2, + getRelayFallbacksFn: () => ['wss://fallback-bad'], + }), + /Failed relay publish quorum in publish phase: 0\/2 successful \(required=2, timeout=200ms\)/, + ); + }); }); }); diff --git a/test/torch-config.test.mjs b/test/torch-config.test.mjs index 91ea67b..00b892d 100644 --- a/test/torch-config.test.mjs +++ b/test/torch-config.test.mjs @@ -1,8 +1,18 @@ -import { test, describe, it, after, beforeEach } from 'node:test'; +import { describe, it, after, beforeEach } from 'node:test'; import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; -import { getTorchConfigPath, parseTorchConfig, loadTorchConfig, _resetTorchConfigCache, getRelays } from '../src/torch-config.mjs'; +import { + getTorchConfigPath, + parseTorchConfig, + loadTorchConfig, + _resetTorchConfigCache, + getRelays, + getQueryTimeoutMs, + getPublishTimeoutMs, + getMinSuccessfulRelayPublishes, + getRelayFallbacks, +} from '../src/torch-config.mjs'; import { DEFAULT_RELAYS } from '../src/constants.mjs'; describe('torch-config', () => { @@ -10,113 +20,40 @@ describe('torch-config', () => { const originalEnv = process.env.TORCH_CONFIG_PATH; after(() => { - if (originalEnv === undefined) { - delete process.env.TORCH_CONFIG_PATH; - } else { - process.env.TORCH_CONFIG_PATH = originalEnv; - } + if (originalEnv === undefined) delete process.env.TORCH_CONFIG_PATH; + else process.env.TORCH_CONFIG_PATH = originalEnv; }); it('returns default path when env var is not set', () => { delete process.env.TORCH_CONFIG_PATH; - const expected = path.resolve(process.cwd(), 'torch-config.json'); - assert.strictEqual(getTorchConfigPath(), expected); + assert.strictEqual(getTorchConfigPath(), path.resolve(process.cwd(), 'torch-config.json')); }); it('returns custom path when env var is set', () => { process.env.TORCH_CONFIG_PATH = 'custom-config.json'; - const expected = path.resolve(process.cwd(), 'custom-config.json'); - assert.strictEqual(getTorchConfigPath(), expected); - }); - - it('trims env var value', () => { - process.env.TORCH_CONFIG_PATH = ' trimmed-config.json '; - const expected = path.resolve(process.cwd(), 'trimmed-config.json'); - assert.strictEqual(getTorchConfigPath(), expected); + assert.strictEqual(getTorchConfigPath(), path.resolve(process.cwd(), 'custom-config.json')); }); }); describe('parseTorchConfig', () => { - it('returns default values for empty raw config', () => { - const config = parseTorchConfig({}, 'some-path.json'); - assert.strictEqual(config.configPath, 'some-path.json'); - assert.deepStrictEqual(config.raw, {}); - assert.strictEqual(config.nostrLock.namespace, null); - assert.strictEqual(config.dashboard.defaultCadenceView, 'daily'); - assert.strictEqual(config.dashboard.defaultStatusView, 'active'); - assert.deepStrictEqual(config.scheduler.paused.daily, []); - }); - - it('correctly parses and normalizes nostrLock config', () => { - const raw = { + it('parses new lock backend knobs', () => { + const config = parseTorchConfig({ nostrLock: { namespace: ' test ', - relays: [' wss://relay1 ', '', 'wss://relay2'], - ttlSeconds: 3600.5, + relays: ['wss://relay1'], + relayFallbacks: [' wss://fallback-1 ', 'wss://fallback-2'], queryTimeoutMs: 5000, - dailyRoster: ['agent1', 'agent2'], - } - }; - const config = parseTorchConfig(raw); + publishTimeoutMs: 8000, + minSuccessfulRelayPublishes: 2, + }, + }); + assert.strictEqual(config.nostrLock.namespace, 'test'); - assert.deepStrictEqual(config.nostrLock.relays, ['wss://relay1', 'wss://relay2']); - assert.strictEqual(config.nostrLock.ttlSeconds, 3600); + assert.deepStrictEqual(config.nostrLock.relays, ['wss://relay1']); + assert.deepStrictEqual(config.nostrLock.relayFallbacks, ['wss://fallback-1', 'wss://fallback-2']); assert.strictEqual(config.nostrLock.queryTimeoutMs, 5000); - assert.deepStrictEqual(config.nostrLock.dailyRoster, ['agent1', 'agent2']); - }); - - it('normalizes cadence and status in dashboard config', () => { - const raw = { - dashboard: { - defaultCadenceView: ' WEEKLY ', - defaultStatusView: ' ALL ' - } - }; - const config = parseTorchConfig(raw); - assert.strictEqual(config.dashboard.defaultCadenceView, 'weekly'); - assert.strictEqual(config.dashboard.defaultStatusView, 'all'); - }); - - it('falls back to defaults for invalid cadence/status', () => { - const raw = { - dashboard: { - defaultCadenceView: 'invalid', - defaultStatusView: 'invalid' - } - }; - const config = parseTorchConfig(raw); - assert.strictEqual(config.dashboard.defaultCadenceView, 'daily'); - assert.strictEqual(config.dashboard.defaultStatusView, 'active'); - }); - - it('correctly parses scheduler config', () => { - const raw = { - scheduler: { - firstPromptByCadence: { - daily: ' daily-prompt.md ', - weekly: ' weekly-prompt.md ' - }, - paused: { - daily: [' paused1 '], - weekly: [' paused2 '] - } - } - }; - const config = parseTorchConfig(raw); - assert.strictEqual(config.scheduler.firstPromptByCadence.daily, 'daily-prompt.md'); - assert.strictEqual(config.scheduler.firstPromptByCadence.weekly, 'weekly-prompt.md'); - assert.deepStrictEqual(config.scheduler.paused.daily, ['paused1']); - assert.deepStrictEqual(config.scheduler.paused.weekly, ['paused2']); - }); - - it('handles non-array roster values by returning null or empty array', () => { - const raw = { - nostrLock: { dailyRoster: 'not-an-array' }, - scheduler: { paused: { daily: 'not-an-array' } } - }; - const config = parseTorchConfig(raw); - assert.strictEqual(config.nostrLock.dailyRoster, null); - assert.deepStrictEqual(config.scheduler.paused.daily, []); + assert.strictEqual(config.nostrLock.publishTimeoutMs, 8000); + assert.strictEqual(config.nostrLock.minSuccessfulRelayPublishes, 2); }); }); @@ -133,82 +70,74 @@ describe('torch-config', () => { after(() => { if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath); if (fs.existsSync(badConfigPath)) fs.unlinkSync(badConfigPath); - if (originalEnv === undefined) { - delete process.env.TORCH_CONFIG_PATH; - } else { - process.env.TORCH_CONFIG_PATH = originalEnv; - } + if (originalEnv === undefined) delete process.env.TORCH_CONFIG_PATH; + else process.env.TORCH_CONFIG_PATH = originalEnv; _resetTorchConfigCache(); }); - it('loads config from disk', () => { - const raw = { nostrLock: { namespace: 'from-disk' } }; - fs.writeFileSync(tempConfigPath, JSON.stringify(raw)); + it('throws fatal error for invalid relay URL', () => { + fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { relays: ['https://bad'] } })); process.env.TORCH_CONFIG_PATH = tempConfigPath; - - const config = loadTorchConfig(); - assert.strictEqual(config.nostrLock.namespace, 'from-disk'); - assert.strictEqual(config.configPath, tempConfigPath); + assert.throws(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/); }); - it('returns defaults if file does not exist', () => { - process.env.TORCH_CONFIG_PATH = 'non-existent.json'; - const config = loadTorchConfig(); - assert.strictEqual(config.nostrLock.namespace, null); + it('throws fatal error for invalid timeout range', () => { + fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { queryTimeoutMs: 50 } })); + process.env.TORCH_CONFIG_PATH = tempConfigPath; + assert.throws(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/); }); it('throws error for malformed JSON', () => { - fs.writeFileSync(badConfigPath, '{ invalid json }'); - process.env.TORCH_CONFIG_PATH = badConfigPath; - - assert.throws(() => { - loadTorchConfig(); - }, /Failed to parse/); - }); - - it('caches the config', () => { - const raw1 = { nostrLock: { namespace: 'first' } }; - fs.writeFileSync(tempConfigPath, JSON.stringify(raw1)); - process.env.TORCH_CONFIG_PATH = tempConfigPath; - - const config1 = loadTorchConfig(); - assert.strictEqual(config1.nostrLock.namespace, 'first'); - - const raw2 = { nostrLock: { namespace: 'second' } }; - fs.writeFileSync(tempConfigPath, JSON.stringify(raw2)); - - const config2 = loadTorchConfig(); - assert.strictEqual(config2.nostrLock.namespace, 'first', 'Should return cached value'); - assert.strictEqual(config1, config2, 'Should be the same object'); + fs.writeFileSync(badConfigPath, '{ invalid json }'); + process.env.TORCH_CONFIG_PATH = badConfigPath; + assert.throws(() => loadTorchConfig(), /Failed to parse/); }); }); - describe('getRelays', () => { - const originalEnv = process.env.NOSTR_LOCK_RELAYS; + describe('backend getters', () => { + const originalEnv = { + NOSTR_LOCK_RELAYS: process.env.NOSTR_LOCK_RELAYS, + NOSTR_LOCK_QUERY_TIMEOUT_MS: process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS, + NOSTR_LOCK_PUBLISH_TIMEOUT_MS: process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS, + NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES: process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES, + NOSTR_LOCK_RELAY_FALLBACKS: process.env.NOSTR_LOCK_RELAY_FALLBACKS, + }; beforeEach(() => { _resetTorchConfigCache(); delete process.env.NOSTR_LOCK_RELAYS; + delete process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS; + delete process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS; + delete process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES; + delete process.env.NOSTR_LOCK_RELAY_FALLBACKS; }); after(() => { - if (originalEnv === undefined) { - delete process.env.NOSTR_LOCK_RELAYS; - } else { - process.env.NOSTR_LOCK_RELAYS = originalEnv; + for (const [k, v] of Object.entries(originalEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; } _resetTorchConfigCache(); }); - it('returns default relays when no config or env var is set', () => { - const relays = getRelays(); - assert.deepStrictEqual(relays, DEFAULT_RELAYS); + it('returns default relays', () => { + assert.deepStrictEqual(getRelays(), DEFAULT_RELAYS); }); - it('returns relays from env var if set', () => { - process.env.NOSTR_LOCK_RELAYS = 'wss://env.relay,wss://env.relay2'; - const relays = getRelays(); - assert.deepStrictEqual(relays, ['wss://env.relay', 'wss://env.relay2']); + it('validates env knob values', () => { + process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '99'; + assert.throws(() => getQueryTimeoutMs(), /NOSTR_LOCK_QUERY_TIMEOUT_MS/); + + process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '5000'; + process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS = 'abc'; + assert.throws(() => getPublishTimeoutMs(), /NOSTR_LOCK_PUBLISH_TIMEOUT_MS/); + + process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS = '8000'; + process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES = '0'; + assert.throws(() => getMinSuccessfulRelayPublishes(), /NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES/); + + process.env.NOSTR_LOCK_RELAY_FALLBACKS = 'https://bad'; + assert.throws(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/); }); }); }); diff --git a/torch-config.example.json b/torch-config.example.json index 3441fd8..701e9b3 100644 --- a/torch-config.example.json +++ b/torch-config.example.json @@ -7,7 +7,13 @@ "wss://relay.primal.net" ], "ttlSeconds": 7200, - "queryTimeoutMs": 15000 + "queryTimeoutMs": 15000, + "publishTimeoutMs": 8000, + "minSuccessfulRelayPublishes": 2, + "relayFallbacks": [ + "wss://relay.snort.social", + "wss://relay.nostr.band" + ] }, "dashboard": { "defaultCadenceView": "daily",