diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json deleted file mode 100644 index bc808a5..0000000 --- a/.scheduler-memory/memory-store.json +++ /dev/null @@ -1,403 +0,0 @@ -[ - [ - "638c2d47-3218-42f5-bc2d-e3adeb4626d0", - { - "schema_version": 1, - "id": "638c2d47-3218-42f5-bc2d-e3adeb4626d0", - "agent_id": "test-agent", - "session_id": "test-run", - "type": "batch_window", - "content": "Store memory event A for daily :: scheduler memory store\nStore memory event B for daily :: scheduler memory store", - "summary": "Store memory event A for daily :: scheduler memory store Store memory event B for daily :: scheduler memory store", - "tags": [ - "scheduler", - "daily", - "store" - ], - "importance": 0.55, - "embedding_id": null, - "created_at": 1771295950236, - "last_seen": 1771295958659, - "source": "scheduler-store", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "5c458e0c-8239-46f9-99d1-b61cf05acc34", - { - "schema_version": 1, - "id": "5c458e0c-8239-46f9-99d1-b61cf05acc34", - "agent_id": "test-agent", - "session_id": "test-run-2", - "type": "batch_window", - "content": "Memory retrieval seed for daily :: scheduler memory retrieval", - "summary": "Memory retrieval seed for daily :: scheduler memory retrieval", - "tags": [ - "scheduler", - "daily", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771295958645, - "last_seen": 1771295958659, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "2e9129bd-c366-4d82-ac2b-7e50574532a9", - { - "schema_version": 1, - "id": "2e9129bd-c366-4d82-ac2b-7e50574532a9", - "agent_id": "onboarding-audit-agent", - "session_id": "initial-seed", - "type": "batch_window", - "content": "Store memory event A for daily :: Required startup + artifacts + memory + issue capture\nStore memory event B for daily :: Required startup + artifacts + memory + issue capture", - "summary": "Store memory event A for daily :: Required startup + artifacts + memory + issue capture Store memory event B for daily :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "daily", - "store" - ], - "importance": 0.55, - "embedding_id": null, - "created_at": 1771296574057, - "last_seen": 1771296574172, - "source": "scheduler-store", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "2612b9ce-1aa7-4731-acb2-c3a852eeaac6", - { - "schema_version": 1, - "id": "2612b9ce-1aa7-4731-acb2-c3a852eeaac6", - "agent_id": "onboarding-audit-agent", - "session_id": "initial-seed", - "type": "batch_window", - "content": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "daily", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771296574158, - "last_seen": 1771296574172, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "facf005a-dad0-4066-921c-49a8e408665c", - { - "schema_version": 1, - "id": "facf005a-dad0-4066-921c-49a8e408665c", - "agent_id": "agent-telemetry", - "session_id": "s-telemetry", - "type": "batch_window", - "content": "customer token ABC123 secret", - "summary": "customer token ABC123 secret", - "tags": [ - "incident" - ], - "importance": 0.25, - "embedding_id": null, - "created_at": 1771296636016, - "last_seen": 1771301051605, - "source": "ingest", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "19ef312e-a725-4544-850e-651713e2d9dc", - { - "schema_version": 1, - "id": "19ef312e-a725-4544-850e-651713e2d9dc", - "agent_id": "bug-reproducer-agent", - "session_id": "session-mlq2vq5p", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771300999213, - "last_seen": 1771300999222, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "bfbb3c6e-095d-4ccd-ad5a-140b6927421c", - { - "schema_version": 1, - "id": "bfbb3c6e-095d-4ccd-ad5a-140b6927421c", - "agent_id": "agent-telemetry", - "session_id": "s-telemetry", - "type": "batch_window", - "content": "customer token ABC123 secret", - "summary": "customer token ABC123 secret", - "tags": [ - "incident" - ], - "importance": 0.25, - "embedding_id": null, - "created_at": 1771301051598, - "last_seen": 1771301051605, - "source": "ingest", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "d74e3e34-6cf1-465e-b4a8-64acd0a8d803", - { - "schema_version": 1, - "id": "d74e3e34-6cf1-465e-b4a8-64acd0a8d803", - "agent_id": "bug-reproducer-agent", - "session_id": "session-mlq2ysd2", - "type": "batch_window", - "content": "Reproduced npm test timeout issue. Created reproduction script in artifacts/reproducers/npm-test-timeout/. Confirmed that `npm test` hangs/times out as expected.", - "summary": "Reproduced npm test timeout issue. Created reproduction script in artifacts/reproducers/npm-test-timeout/. Confirmed that `npm test` hangs/times out as expected.", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771301142040, - "last_seen": 1771301142040, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "3df7bccf-f3f1-45b9-ab15-88a1a30c6453", - { - "schema_version": 1, - "id": "3df7bccf-f3f1-45b9-ab15-88a1a30c6453", - "agent_id": "changelog-agent", - "session_id": "session-mlq51t0r", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771304642091, - "last_seen": 1771304642101, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "5b32be34-78f9-44ac-b7b1-e301e34f04d0", - { - "schema_version": 1, - "id": "5b32be34-78f9-44ac-b7b1-e301e34f04d0", - "agent_id": "changelog-agent", - "session_id": "session-mlq53the", - "type": "batch_window", - "content": "# Changelog Update - 2026-02-17\n\nGenerated draft release notes covering 212 commits.\nKey areas:\n- Scheduler automation improvements\n- Memory management features\n- Codebase modularization", - "summary": "# Changelog Update - 2026-02-17\n\nGenerated draft release notes covering 212 commits.\nKey areas:\n- Scheduler automation improvements\n- Memory management features\n- Codebase modularization", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771304736005, - "last_seen": 1771304736005, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "40f6bf1a-9715-4906-82d1-8f68be78eb3f", - { - "schema_version": 1, - "id": "40f6bf1a-9715-4906-82d1-8f68be78eb3f", - "agent_id": "dead-code-agent", - "session_id": "1771338346", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771338346815, - "last_seen": 1771338346833, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "267412f9-9704-4739-8481-8317a89e6769", - { - "schema_version": 1, - "id": "267412f9-9704-4739-8481-8317a89e6769", - "agent_id": "dead-code-agent", - "session_id": "1771338501", - "type": "batch_window", - "content": "# Weekly Dead Code Analysis - 2026-02-17\n\n**Agent:** dead-code-agent\n**Cadence:** weekly\n**Status:** Completed\n\n## Summary\nExecuted a safety-first dead-code sweep.\n\n## Findings\n- Identified `scripts/fix-duplication.mjs` as an unused standalone script.\n- Identified `src/services/memory/db-adapter.js` as an unused module (exports `createMemoryRepository` but not imported anywhere).\n\n## Actions\n- Verified zero usage for both files using `grep`.\n- Deleted both files in branch `ai/dead-code-20260217`.\n- Verified deletions with `ls` and ran `npm run lint` and `npm run test:unit:lock-backend`.\n- Artifacts generated in `artifacts/dead-code-20260217/`.", - "summary": "# Weekly Dead Code Analysis - 2026-02-17\n\n**Agent:** dead-code-agent\n**Cadence:** weekly\n**Status:** Completed\n\n## Summary\nExecuted a safety-first dead-code sweep.\n\n## Findings\n- Identified `scripts/fix-duplication.mjs` as an unused standalone script.\n- Identified `src/services/…", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771338501891, - "last_seen": 1771338501891, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "fd852345-c9c5-4c5c-8076-7e16a7b90016", - { - "schema_version": 1, - "id": "fd852345-c9c5-4c5c-8076-7e16a7b90016", - "agent_id": "feature-proposer-agent", - "session_id": "session-1771346034", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771346034674, - "last_seen": 1771346034684, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "abe5bddd-5b7a-4e18-aabf-9d517bd59a8e", - { - "schema_version": 1, - "id": "abe5bddd-5b7a-4e18-aabf-9d517bd59a8e", - "agent_id": "feature-proposer-agent", - "session_id": "session-1771346112", - "type": "batch_window", - "content": "# Memory Update - Feature Proposer Agent\n\n**Date**: 2026-02-17T15-00-00Z\n**Agent**: feature-proposer-agent\n\n## Insights\n- The `task-logs` structure is consistent and easy to parse.\n- A simple CLI tool (`features/log-summary.mjs`) was added to visualize this data.\n- This pattern of \"small, standalone utility scripts\" in `features/` is effective for incremental improvement.\n\n## Actions\n- Implemented `features/log-summary.mjs`.\n- Verified with manual execution.\n\n## Recommendations\n- Future agents could enhance this script to filter by status (e.g., show only failed runs).", - "summary": "# Memory Update - Feature Proposer Agent\n\n**Date**: 2026-02-17T15-00-00Z\n**Agent**: feature-proposer-agent\n\n## Insights\n- The `task-logs` structure is consistent and easy to parse.\n- A simple CLI tool (`features/log-summary.mjs`) was added to visualize this data.\n- This pattern …", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771346112755, - "last_seen": 1771346112755, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "9f17b770-803f-401b-8bec-4c8ce413f232", - { - "schema_version": 1, - "id": "9f17b770-803f-401b-8bec-4c8ce413f232", - "agent_id": "audit-agent", - "session_id": "session-mlqwxsc8", - "type": "batch_window", - "content": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "daily", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771351483834, - "last_seen": 1771351483843, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "30b53ec3-5d17-4b38-9d80-4f9c15e3cd7e", - { - "schema_version": 1, - "id": "30b53ec3-5d17-4b38-9d80-4f9c15e3cd7e", - "agent_id": "audit-agent", - "session_id": "session-mlqwzwl4", - "type": "batch_window", - "content": "# Audit Agent Report (2026-02-17)\n\n## Metrics\n- Oversized files: 18 (4010 excess lines)\n- InnerHTML assignments: 5\n- Lint failures: 13\n\n## Findings\n- Top oversized: scripts/agent/run-scheduler-cycle.mjs (1092 lines)\n- InnerHTML usage in dashboard/index.html and landing/index.html.\n- Lint warnings mostly unused vars.", - "summary": "# Audit Agent Report (2026-02-17)\n\n## Metrics\n- Oversized files: 18 (4010 excess lines)\n- InnerHTML assignments: 5\n- Lint failures: 13\n\n## Findings\n- Top oversized: scripts/agent/run-scheduler-cycle.mjs (1092 lines)\n- InnerHTML usage in dashboard/index.html and landing/index.htm…", - "tags": [ - "scheduler", - "daily", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771351582653, - "last_seen": 1771351582653, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ] -] \ No newline at end of file diff --git a/src/cmd-check.mjs b/src/cmd-check.mjs index ce98ea4..2ca8fe3 100644 --- a/src/cmd-check.mjs +++ b/src/cmd-check.mjs @@ -8,10 +8,10 @@ import { queryLocks } from './lock-ops.mjs'; import { todayDateStr } from './utils.mjs'; export async function cmdCheck(cadence) { - const relays = getRelays(); - const namespace = getNamespace(); + const relays = await getRelays(); + const namespace = await getNamespace(); const dateStr = todayDateStr(); - const config = loadTorchConfig(); + const config = await loadTorchConfig(); const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); @@ -20,9 +20,9 @@ export async function cmdCheck(cadence) { console.error(`Paused agents: ${pausedAgents.join(', ')}`); } - const locks = await queryLocks(relays, cadence, dateStr, namespace); + const locks = await queryLocksFn(relays, cadence, dateStr, namespace); const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; - const roster = getRoster(cadence); + const roster = await getRoster(cadence); const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])]; const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent)); diff --git a/src/cmd-list.mjs b/src/cmd-list.mjs index 727b982..65a9166 100644 --- a/src/cmd-list.mjs +++ b/src/cmd-list.mjs @@ -7,8 +7,8 @@ import { queryLocks } from './lock-ops.mjs'; import { todayDateStr, nowUnix } from './utils.mjs'; export async function cmdList(cadence) { - const relays = getRelays(); - const namespace = getNamespace(); + const relays = await getRelays(); + const namespace = await getNamespace(); const dateStr = todayDateStr(); const cadences = cadence ? [cadence] : ['daily', 'weekly']; @@ -47,7 +47,7 @@ export async function cmdList(cadence) { ); } - const roster = getRoster(c); + const roster = await getRoster(c); const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent)); const available = roster.filter((a) => !lockedAgents.has(a)); diff --git a/src/cmd-lock.mjs b/src/cmd-lock.mjs index ca4318c..195baa5 100644 --- a/src/cmd-lock.mjs +++ b/src/cmd-lock.mjs @@ -14,10 +14,10 @@ import { todayDateStr, nowUnix } from './utils.mjs'; import { ExitError } from './errors.mjs'; export async function cmdLock(agent, cadence, dryRun = false) { - const relays = getRelays(); - const namespace = getNamespace(); + const relays = await getRelays(); + const namespace = await getNamespace(); const dateStr = todayDateStr(); - const ttl = getTtl(); + const ttl = await getTtl(); const now = nowUnix(); const expiresAt = now + ttl; @@ -25,7 +25,7 @@ export async function cmdLock(agent, cadence, dryRun = false) { console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); console.error(`Relays: ${relays.join(', ')}`); - const roster = getRoster(cadence); + const roster = await getRoster(cadence); if (!roster.includes(agent)) { console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`); diff --git a/src/dashboard.mjs b/src/dashboard.mjs index caeb578..bf9eea2 100644 --- a/src/dashboard.mjs +++ b/src/dashboard.mjs @@ -22,9 +22,10 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. // Resolve package root relative to this file (src/dashboard.mjs) // this file is in /src/dashboard.mjs, so '..' goes to const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const auth = getDashboardAuth(); + const auth = await getDashboardAuth(); const server = http.createServer(async (req, res) => { + try { // Basic Auth check if (auth) { const authHeader = req.headers.authorization; @@ -181,20 +182,28 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. res.writeHead(200, { 'Content-Type': contentType }); fs.createReadStream(filePath).pipe(res); - }); - - server.listen(port, host, () => { - const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`; - console.log(`Dashboard running at ${listenUrl}`); - if (auth) { - console.log('Authentication: enabled (Basic Auth)'); - } else { - console.warn('Authentication: DISABLED (Dashboard is public)'); + } catch (err) { + console.error('Dashboard Server Error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } } - console.log(`Serving files from ${packageRoot}`); - console.log(`Using configuration from ${process.cwd()}`); }); - // Keep process alive - return server; + return new Promise((resolve, reject) => { + server.listen(port, host, () => { + const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`; + console.log(`Dashboard running at ${listenUrl}`); + if (auth) { + console.log('Authentication: enabled (Basic Auth)'); + } else { + console.warn('Authentication: DISABLED (Dashboard is public)'); + } + console.log(`Serving files from ${packageRoot}`); + console.log(`Using configuration from ${process.cwd()}`); + resolve(server); + }); + server.on('error', reject); + }); } diff --git a/src/lib.mjs b/src/lib.mjs index e002fb5..ec97d94 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -75,10 +75,10 @@ export async function cmdCheck(cadence, deps = {}) { quiet = false, } = deps; - const relays = getRelays(); - const namespace = getNamespace(); + const relays = await getRelays(); + const namespace = await getNamespace(); const dateStr = getDateStr(); - const config = loadTorchConfig(); + const config = await loadTorchConfig(); const pausedAgents = config.scheduler.paused[cadence] || []; if (!quiet) { @@ -99,7 +99,7 @@ export async function cmdCheck(cadence, deps = {}) { const locks = await queryLocks(relays, cadence, dateStr, namespace); const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; - const roster = getRoster(cadence); + const roster = await getRoster(cadence); const rosterSet = new Set(roster); const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents, ...completedAgents]); @@ -180,11 +180,11 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {} error = console.error } = deps; - const relays = getRelays(); - const namespace = getNamespace(); - const hashtag = getHashtag(); + const relays = await getRelays(); + const namespace = await getNamespace(); + const hashtag = await getHashtag(); const dateStr = getDateStr(); - const ttl = getTtl(); + const ttl = await getTtl(); const now = nowUnix(); const expiresAt = now + ttl; @@ -193,7 +193,7 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {} error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); error(`Relays: ${relays.join(', ')}`); - const roster = getRoster(cadence); + const roster = await getRoster(cadence); if (!roster.includes(agent)) { error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); error(`Allowed ${cadence} agents: ${roster.join(', ')}`); @@ -319,8 +319,8 @@ export async function cmdList(cadence, deps = {}) { error = console.error } = deps; - const relays = getRelays(); - const namespace = getNamespace(); + const relays = await getRelays(); + const namespace = await getNamespace(); const dateStr = getDateStr(); const cadences = cadence ? [cadence] : [...VALID_CADENCES]; @@ -365,7 +365,7 @@ export async function cmdList(cadence, deps = {}) { ); } - const roster = getRoster(c); + const roster = await getRoster(c); const rosterSet = new Set(roster); const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); const unknownLockedAgents = [...lockedAgents].filter((agent) => !rosterSet.has(agent)); @@ -413,9 +413,9 @@ export async function cmdComplete(agent, cadence, optionsOrDryRun = false, deps error = console.error } = deps; - const relays = getRelays(); - const namespace = getNamespace(); - const hashtag = getHashtag(); + const relays = await getRelays(); + const namespace = await getNamespace(); + const hashtag = await getHashtag(); const dateStr = getDateStr(); const now = nowUnix(); diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index df31b7e..3b7f38b 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -306,11 +306,11 @@ export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) const pool = poolFactory(); const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; - const queryTimeoutMs = getQueryTimeoutMsFn(); - const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay)); + const queryTimeoutMs = await getQueryTimeoutMsFn(); + const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay)); const healthConfig = buildRelayHealthConfig({ ...deps, - minActiveRelayPool: getMinActiveRelayPoolFn(), + minActiveRelayPool: await getMinActiveRelayPoolFn(), }); const runQuery = async (relaySet, phase) => { @@ -419,13 +419,13 @@ export async function publishLock(relays, event, deps = {}) { const attemptId = diagnostics.attemptId || process.env.SCHEDULER_LOCK_ATTEMPT_ID || '1'; const pool = poolFactory(); - const publishTimeoutMs = getPublishTimeoutMsFn(); - const minSuccesses = getMinSuccessfulRelayPublishesFn(); - const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay)); + const publishTimeoutMs = await getPublishTimeoutMsFn(); + const minSuccesses = await getMinSuccessfulRelayPublishesFn(); + const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay)); const maxAttempts = Math.max(1, Math.floor(retryAttempts)); const healthConfig = buildRelayHealthConfig({ ...deps, - minActiveRelayPool: getMinActiveRelayPoolFn(), + minActiveRelayPool: await getMinActiveRelayPoolFn(), }); const publishOnce = async () => { diff --git a/src/perf/benchmark-config-load.mjs b/src/perf/benchmark-config-load.mjs new file mode 100644 index 0000000..4e00f1d --- /dev/null +++ b/src/perf/benchmark-config-load.mjs @@ -0,0 +1,45 @@ +import { loadTorchConfig, _resetTorchConfigCache } from '../torch-config.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const configPath = path.resolve(process.cwd(), 'torch-config.json'); +const originalConfig = fs.existsSync(configPath) ? fs.readFileSync(configPath) : null; + +// Ensure a config file exists for benchmark +if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, JSON.stringify({ + nostrLock: { + relays: ["wss://relay.damus.io"], + namespace: "test-ns" + } + }, null, 2)); +} + +const ITERATIONS = 1000; +const start = process.hrtime.bigint(); + +async function run() { + for (let i = 0; i < ITERATIONS; i++) { + _resetTorchConfigCache(); + try { + const res = await loadTorchConfig(); + // Use res to prevent dead code elimination if JIT is smart (though side effects exist) + if (!res) throw new Error("Failed"); + } catch (e) { + // ignore + } + } + + const end = process.hrtime.bigint(); + const duration = Number(end - start) / 1e6; // ms + + console.log(`[ASYNC] Loaded config ${ITERATIONS} times in ${duration.toFixed(2)}ms`); + console.log(`[ASYNC] Average: ${(duration / ITERATIONS).toFixed(4)}ms per load`); + + // Cleanup + if (!originalConfig && fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + } +} + +run(); diff --git a/src/relay-health.mjs b/src/relay-health.mjs index 67c4943..0c0a824 100644 --- a/src/relay-health.mjs +++ b/src/relay-health.mjs @@ -196,15 +196,22 @@ async function appendHistory(historyPath, entry) { export async function runRelayHealthCheck(options = {}) { const { cadence = 'daily', - relays = getRelays(), - namespace = getNamespace(), timeoutMs = 6000, - historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`), allRelaysDownMinutes = 10, minSuccessRate = 0.7, windowMinutes = 60, } = options; + let { + relays, + namespace, + historyPath, + } = options; + + if (!relays) relays = await getRelays(); + if (!namespace) namespace = await getNamespace(); + if (!historyPath) historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`); + const relayResults = []; for (const relay of relays) { const relayResult = { diff --git a/src/roster.mjs b/src/roster.mjs index bfac153..99ab414 100644 --- a/src/roster.mjs +++ b/src/roster.mjs @@ -94,8 +94,8 @@ function parseEnvRoster(value) { .filter(Boolean); } -export function getRoster(cadence) { - const config = loadTorchConfig(); +export async function getRoster(cadence) { + const config = await loadTorchConfig(); const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); const canonical = loadCanonicalRoster(); diff --git a/src/torch-config.mjs b/src/torch-config.mjs index 8b03dc6..a759051 100644 --- a/src/torch-config.mjs +++ b/src/torch-config.mjs @@ -127,18 +127,20 @@ export function _resetTorchConfigCache() { cachedConfig = null; } -export function loadTorchConfig(fileSystem = fs) { +export async function loadTorchConfig(fileSystem = fs) { if (cachedConfig) return cachedConfig; const configPath = getTorchConfigPath(); let raw = {}; - if (fileSystem.existsSync(configPath)) { - try { - raw = JSON.parse(fileSystem.readFileSync(configPath, 'utf8')); - } catch (err) { + try { + const content = await fileSystem.promises.readFile(configPath, 'utf8'); + raw = JSON.parse(content); + } catch (err) { + if (err.code !== 'ENOENT') { throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err }); } + // If file missing (ENOENT), we use empty object defaults } cachedConfig = parseTorchConfig(raw, configPath); @@ -199,8 +201,8 @@ function resolveIntegerConfig(envKey, configValue, defaultValue, validator, conf return value; } -export function getRelays() { - const config = loadTorchConfig(); +export async function getRelays() { + const config = await loadTorchConfig(); const envRelays = process.env.NOSTR_LOCK_RELAYS; if (envRelays) { return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS'); @@ -214,8 +216,8 @@ export function getRelays() { return DEFAULT_RELAYS; } -export function getRelayFallbacks() { - const config = loadTorchConfig(); +export async function getRelayFallbacks() { + const config = await loadTorchConfig(); const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS; if (envRelays) { return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS'); @@ -223,14 +225,14 @@ export function getRelayFallbacks() { return config.nostrLock.relayFallbacks || []; } -export function getNamespace() { - const config = loadTorchConfig(); +export async function getNamespace() { + const config = await loadTorchConfig(); const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim(); return namespace || DEFAULT_NAMESPACE; } -export function getTtl() { - const config = loadTorchConfig(); +export async function getTtl() { + const config = await loadTorchConfig(); const envTtl = process.env.NOSTR_LOCK_TTL; if (envTtl) { const parsed = parseInt(envTtl, 10); @@ -242,8 +244,8 @@ export function getTtl() { return DEFAULT_TTL; } -export function getQueryTimeoutMs() { - const config = loadTorchConfig(); +export async function getQueryTimeoutMs() { + const config = await loadTorchConfig(); return resolveIntegerConfig( 'NOSTR_LOCK_QUERY_TIMEOUT_MS', config.nostrLock.queryTimeoutMs, @@ -253,8 +255,8 @@ export function getQueryTimeoutMs() { ); } -export function getPublishTimeoutMs() { - const config = loadTorchConfig(); +export async function getPublishTimeoutMs() { + const config = await loadTorchConfig(); return resolveIntegerConfig( 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS', config.nostrLock.publishTimeoutMs, @@ -264,8 +266,8 @@ export function getPublishTimeoutMs() { ); } -export function getMinSuccessfulRelayPublishes() { - const config = loadTorchConfig(); +export async function getMinSuccessfulRelayPublishes() { + const config = await loadTorchConfig(); return resolveIntegerConfig( 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES', config.nostrLock.minSuccessfulRelayPublishes, @@ -275,8 +277,8 @@ export function getMinSuccessfulRelayPublishes() { ); } -export function getMinActiveRelayPool() { - const config = loadTorchConfig(); +export async function getMinActiveRelayPool() { + const config = await loadTorchConfig(); return resolveIntegerConfig( 'NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL', config.nostrLock.minActiveRelayPool, @@ -286,8 +288,8 @@ export function getMinActiveRelayPool() { ); } -export function getHashtag() { - const config = loadTorchConfig(); +export async function getHashtag() { + const config = await loadTorchConfig(); const envValue = process.env.NOSTR_LOCK_HASHTAG; if (envValue) { return envValue.trim(); @@ -295,12 +297,12 @@ export function getHashtag() { if (config.dashboard.hashtag) { return config.dashboard.hashtag; } - const namespace = getNamespace(); + const namespace = await getNamespace(); return `${namespace}-agent-lock`; } -export function getDashboardAuth() { - const config = loadTorchConfig(); +export async function getDashboardAuth() { + const config = await loadTorchConfig(); const envValue = process.env.TORCH_DASHBOARD_AUTH; if (envValue) { return envValue.trim(); diff --git a/test/cmd-check.test.mjs b/test/cmd-check.test.mjs new file mode 100644 index 0000000..287dd7b --- /dev/null +++ b/test/cmd-check.test.mjs @@ -0,0 +1,187 @@ +import { describe, it, mock, afterEach, after } from 'node:test'; +import assert from 'node:assert'; +import { cmdCheck } from '../src/cmd-check.mjs'; + +// Mocks +const mockLoadTorchConfig = mock.fn(() => ({ + scheduler: { + paused: { + daily: [], + weekly: [], + }, + }, +})); +const mockGetRelays = mock.fn(() => ['wss://mock-relay']); +const mockGetNamespace = mock.fn(() => 'mock-namespace'); +const mockGetRoster = mock.fn(() => []); +const mockQueryLocks = mock.fn(async () => []); +const mockTodayDateStr = mock.fn(() => '2023-10-27'); + +const deps = { + loadTorchConfigFn: mockLoadTorchConfig, + getRelaysFn: mockGetRelays, + getNamespaceFn: mockGetNamespace, + getRosterFn: mockGetRoster, + queryLocksFn: mockQueryLocks, + todayDateStrFn: mockTodayDateStr, +}; + +describe('cmdCheck', () => { + // Capture console output + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + let consoleLogOutput = []; + let consoleErrorOutput = []; + + const mockConsoleLog = mock.fn((msg) => { + consoleLogOutput.push(msg); + }); + const mockConsoleError = mock.fn((msg) => { + consoleErrorOutput.push(msg); + }); + + // Setup console mocks + console.log = mockConsoleLog; + console.error = mockConsoleError; + + afterEach(() => { + // Reset mocks + mockLoadTorchConfig.mock.resetCalls(); + mockGetRelays.mock.resetCalls(); + mockGetNamespace.mock.resetCalls(); + mockGetRoster.mock.resetCalls(); + mockQueryLocks.mock.resetCalls(); + mockTodayDateStr.mock.resetCalls(); + + // Clear captured output + consoleLogOutput = []; + consoleErrorOutput = []; + }); + + // Restore console after all tests + after(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + it('correctly reports available agents when no locks exist', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2']); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.strictEqual(result.cadence, 'daily'); + assert.strictEqual(result.namespace, 'mock-namespace'); + assert.strictEqual(result.date, '2023-10-27'); + assert.deepStrictEqual(result.available, ['agent1', 'agent2']); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.paused, []); + assert.deepStrictEqual(result.excluded, []); + assert.strictEqual(result.lockCount, 0); + + // Verify console output was JSON + assert.strictEqual(consoleLogOutput.length, 1); + const logOutput = JSON.parse(consoleLogOutput[0]); + assert.deepStrictEqual(logOutput.available, ['agent1', 'agent2']); + }); + + it('correctly identifies locked agents', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2', 'agent3']); + mockQueryLocks.mock.mockImplementation(async () => [ + { agent: 'agent1', eventId: 'evt1', createdAtIso: '2023-10-27T00:00:00Z', expiresAtIso: '2023-10-27T01:00:00Z', platform: 'linux' } + ]); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, ['agent2', 'agent3']); + assert.deepStrictEqual(result.locked, ['agent1']); + assert.deepStrictEqual(result.excluded, ['agent1']); + assert.strictEqual(result.lockCount, 1); + assert.strictEqual(result.locks[0].agent, 'agent1'); + }); + + it('correctly identifies paused agents', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2', 'agent3']); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: ['agent2'] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, ['agent1', 'agent3']); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.paused, ['agent2']); + assert.deepStrictEqual(result.excluded, ['agent2']); + }); + + it('handles agents that are both locked and paused', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2']); + mockQueryLocks.mock.mockImplementation(async () => [ + { agent: 'agent1', eventId: 'evt1' } + ]); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: ['agent1'] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, ['agent2']); + assert.deepStrictEqual(result.locked, ['agent1']); + assert.deepStrictEqual(result.paused, ['agent1']); + // excluded should contain unique agents + assert.deepStrictEqual(result.excluded, ['agent1']); + }); + + it('identifies unknown locked agents (not in roster)', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1']); + mockQueryLocks.mock.mockImplementation(async () => [ + { agent: 'agent1', eventId: 'evt1' }, + { agent: 'unknown-agent', eventId: 'evt2' } + ]); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, []); + assert.deepStrictEqual(result.locked, ['agent1', 'unknown-agent']); + assert.deepStrictEqual(result.unknownLockedAgents, ['unknown-agent']); + assert.deepStrictEqual(result.excluded, ['agent1', 'unknown-agent']); + }); + + it('handles weekly cadence', async () => { + mockGetRoster.mock.mockImplementation(() => ['weekly1', 'weekly2']); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { weekly: ['weekly2'] } } + })); + + const result = await cmdCheck('weekly', deps); + + assert.strictEqual(result.cadence, 'weekly'); + assert.deepStrictEqual(result.available, ['weekly1']); + assert.deepStrictEqual(result.paused, ['weekly2']); + }); + + it('handles empty roster', async () => { + mockGetRoster.mock.mockImplementation(() => []); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, []); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.paused, []); + }); +}); diff --git a/test/dashboard-security.test.mjs b/test/dashboard-security.test.mjs index f86e5a5..88391e8 100644 --- a/test/dashboard-security.test.mjs +++ b/test/dashboard-security.test.mjs @@ -5,15 +5,17 @@ import http from 'node:http'; import { cmdDashboard } from '../src/dashboard.mjs'; test('Dashboard File Access Security', async (t) => { - const testPort = 4175; + let testPort = 0; let server; t.beforeEach(async () => { - server = await cmdDashboard(testPort); + server = await cmdDashboard(0); + testPort = server.address().port; }); - t.afterEach(() => { - if (server) server.close(); + t.afterEach((done) => { + if (server) server.close(done); + else done(); }); function get(path) { diff --git a/test/lock-utils.test.mjs b/test/lock-utils.test.mjs new file mode 100644 index 0000000..1bdac59 --- /dev/null +++ b/test/lock-utils.test.mjs @@ -0,0 +1,84 @@ +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCompletedAgents } from '../src/lock-utils.mjs'; + +describe('lock-utils', () => { + describe('getCompletedAgents', () => { + // Helper to create deps + const createDeps = (overrides = {}) => ({ + readdir: async () => [], + getDateStr: () => '2023-01-01', + getIsoWeek: () => '2023-W01', + ...overrides, + }); + + it('returns completed agents for daily cadence', async () => { + const deps = createDeps({ + readdir: async () => [ + '2023-01-01T10-00-00Z__agent1__completed.md', + '2023-01-01T11-00-00Z__agent2__failed.md', + '2023-01-02T10-00-00Z__agent3__completed.md', // different day + ], + getDateStr: () => '2023-01-01', + }); + const result = await getCompletedAgents('daily', 'logs', deps); + assert.deepEqual([...result], ['agent1']); + }); + + it('returns completed agents for weekly cadence', async () => { + const deps = createDeps({ + readdir: async () => [ + '2023-01-01T10-00-00Z__agent1__completed.md', // Week 52 of 2022 (Sunday) - assuming mock logic + '2023-01-02T10-00-00Z__agent2__completed.md', // Week 01 of 2023 (Monday) + ], + getIsoWeek: (date) => { + if (!date) return '2023-W01'; // current week + if (date === '2023-01-01') return '2022-W52'; + if (date === '2023-01-02') return '2023-W01'; + return 'UNKNOWN'; + } + }); + // cadence is weekly + const result = await getCompletedAgents('weekly', 'logs', deps); + assert.deepEqual([...result], ['agent2']); + }); + + it('handles ENOENT gracefully', async () => { + const error = new Error('No such file or directory'); + error.code = 'ENOENT'; + const deps = createDeps({ + readdir: async () => { throw error; } + }); + + // Spy on console.error to ensure it is NOT called + const consoleError = mock.method(console, 'error'); + + const result = await getCompletedAgents('daily', 'logs', deps); + + assert.equal(consoleError.mock.callCount(), 0); + assert.deepEqual([...result], []); + consoleError.mock.restore(); + }); + + it('logs error for non-ENOENT errors', async () => { + const error = new Error('Permission denied'); + error.code = 'EACCES'; + const deps = createDeps({ + readdir: async () => { throw error; } + }); + + // Spy on console.error to verify it IS called + const consoleError = mock.method(console, 'error'); + + const result = await getCompletedAgents('daily', 'logs', deps); + + assert.equal(consoleError.mock.callCount(), 1); + const call = consoleError.mock.calls[0]; + assert.match(call.arguments[0], /Warning: Failed to read log dir/); + assert.match(call.arguments[0], /Permission denied/); + + assert.deepEqual([...result], []); + consoleError.mock.restore(); + }); + }); +}); diff --git a/test/memory-formatter.test.mjs b/test/memory-formatter.test.mjs index 6b1e506..9b7717e 100644 --- a/test/memory-formatter.test.mjs +++ b/test/memory-formatter.test.mjs @@ -224,3 +224,218 @@ test('renderPromptWithMemoryContext handles circular agentContext gracefully', a assert.ok(capturedQuery.includes('[object Object]'), 'Query should contain the stringified circular object fallback'); assert.match(output, /Base prompt/); }); + +// Added tests below + +test('renderPromptWithMemoryContext handles agentContext as array of strings', async () => { + let capturedQuery = ''; + const memoryService = { + async getRelevantMemories({ query }) { + capturedQuery = query; + return []; + }, + async updateMemoryUsage() {} + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agentContext: ['context1', 'context2'], + agent_id: 'agent-1', + memoryService, + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + await renderPromptWithMemoryContext(params); + assert.ok(capturedQuery.includes('context1')); + assert.ok(capturedQuery.includes('context2')); +}); + +test('renderPromptWithMemoryContext handles agentContext as plain object', async () => { + let capturedQuery = ''; + const memoryService = { + async getRelevantMemories({ query }) { + capturedQuery = query; + return []; + }, + async updateMemoryUsage() {} + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agentContext: { key: 'value' }, + agent_id: 'agent-1', + memoryService, + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + await renderPromptWithMemoryContext(params); + assert.ok(capturedQuery.includes('{"key":"value"}')); +}); + +test('renderPromptWithMemoryContext handles agentContext as primitives', async () => { + let capturedQuery = ''; + const memoryService = { + async getRelevantMemories({ query }) { + capturedQuery = query; + return []; + }, + async updateMemoryUsage() {} + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agentContext: 12345, + agent_id: 'agent-1', + memoryService, + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + await renderPromptWithMemoryContext(params); + assert.ok(capturedQuery.includes('12345')); +}); + +test('renderPromptWithMemoryContext calls updateMemoryUsage for retrieved memories', async () => { + const updatedIds = []; + const memoryService = { + async getRelevantMemories() { + return [ + createMemory({ id: 'm1' }), + createMemory({ id: 'm2' }) + ]; + }, + async updateMemoryUsage(id) { + updatedIds.push(id); + } + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agent_id: 'agent-1', + memoryService, + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + await renderPromptWithMemoryContext(params); + assert.deepEqual(updatedIds, ['m1', 'm2']); +}); + +test('renderPromptWithMemoryContext handles empty memories gracefully', async () => { + const memoryService = { + async getRelevantMemories() { + return []; + }, + async updateMemoryUsage() {} + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agent_id: 'agent-1', + memoryService, + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + const output = await renderPromptWithMemoryContext(params); + assert.equal(output, 'Base prompt'); +}); + +test('renderPromptWithMemoryContext handles null/undefined memories gracefully', async () => { + const memoryService = { + async getRelevantMemories() { + return null; + }, + async updateMemoryUsage() {} + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agent_id: 'agent-1', + memoryService, + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + const output = await renderPromptWithMemoryContext(params); + assert.equal(output, 'Base prompt'); +}); + +test('renderPromptWithMemoryContext respects TORCH_MEMORY_ENABLED=false', async () => { + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agent_id: 'agent-1', + memoryService: {}, + env: { + TORCH_MEMORY_ENABLED: 'false', + TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' + } + }; + + const output = await renderPromptWithMemoryContext(params); + assert.equal(output, 'Base prompt'); +}); + +test('renderPromptWithMemoryContext respects retrieval allowlist', async () => { + const memoryService = { + async getRelevantMemories() { return [createMemory({summary: 'Should not see'})]; } + }; + + // agent-2 is not in the allowlist + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agent_id: 'agent-2', + memoryService, + env: { + TORCH_MEMORY_ENABLED: 'true', + TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-1' + } + }; + + const output = await renderPromptWithMemoryContext(params); + assert.equal(output, 'Base prompt'); + + // agent-1 is in the allowlist + const paramsAllowed = { + ...params, + agent_id: 'agent-1' + }; + const outputAllowed = await renderPromptWithMemoryContext(paramsAllowed); + assert.match(outputAllowed, /\[CONTEXT — Relevant memories\]/); +}); + +test('renderPromptWithMemoryContext passes k and tokenBudget', async () => { + const memoryService = { + async getRelevantMemories({ k }) { + assert.equal(k, 3, 'Should pass k=3'); + // Return more memories than k to verify formatter truncation if needed, + // but getRelevantMemories is responsible for limiting too. + // Here we just check if it gets called with k. + return [ + createMemory({id: 'm1'}), + createMemory({id: 'm2'}), + createMemory({id: 'm3'}), + createMemory({id: 'm4'}), + ]; + }, + async updateMemoryUsage() {} + }; + + const params = { + basePrompt: 'Base prompt', + userRequest: 'Hello', + agent_id: 'agent-1', + memoryService, + retrievalK: 3, + tokenBudget: 50, // Small budget + env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' } + }; + + const output = await renderPromptWithMemoryContext(params); + // With small budget, it might truncate m3 or m2. + assert.match(output, /m1/); +}); diff --git a/test/memory-ingest.test.mjs b/test/memory-ingest.test.mjs new file mode 100644 index 0000000..eff7b6d --- /dev/null +++ b/test/memory-ingest.test.mjs @@ -0,0 +1,165 @@ +import { test, describe, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { ingestEvents, getRelevantMemories } from '../src/services/memory/index.js'; + +describe('ingestEvents', () => { + let repository; + let telemetryEvents = []; + let metricsEvents = []; + let storedMemories = []; + let storedFingerprints = new Map(); + let linkedEmbeddings = new Map(); + + const mockOptions = { + embedText: async (text) => Array(128).fill(0.1), // Deterministic embedding + emitTelemetry: (event, payload) => telemetryEvents.push({ event, payload }), + metrics: { + emit: (metric, payload) => metricsEvents.push({ metric, payload }), + }, + // Mock environment to ensure enabled by default + env: { + TORCH_MEMORY_ENABLED: 'true', + TORCH_MEMORY_INGEST_ENABLED: 'true', + }, + now: 1600000000000, // Fixed time + }; + + beforeEach(() => { + telemetryEvents = []; + metricsEvents = []; + storedMemories = []; + storedFingerprints = new Map(); + linkedEmbeddings = new Map(); + + repository = { + async insertMemory(memory) { + storedMemories.push(memory); + return memory; + }, + async hasIngestionFingerprint(key) { + return storedFingerprints.has(key); + }, + async storeIngestionFingerprint(key, payload) { + storedFingerprints.set(key, payload); + }, + async linkEmbedding(id, embedding) { + linkedEmbeddings.set(id, embedding); + }, + // Minimal implementation for getRelevantMemories to work for cache check + async listMemories() { + return storedMemories; + }, + async updateMemoryUsage() {}, + }; + }); + + test('happy path: successfully ingests events', async () => { + const events = [ + { + agent_id: 'agent-1', + content: 'User logged in', + timestamp: mockOptions.now - 1000, + metadata: { session_id: 'session-1', source: 'test' }, + }, + { + agent_id: 'agent-1', + content: 'User clicked button', + timestamp: mockOptions.now - 500, + metadata: { session_id: 'session-1', source: 'test' }, + }, + ]; + + const result = await ingestEvents(events, { ...mockOptions, repository }); + + assert.equal(result.length, 1); // Should be chunked into one memory + assert.equal(storedMemories.length, 1); + assert.equal(storedMemories[0].agent_id, 'agent-1'); + assert.match(storedMemories[0].content, /User logged in/); + assert.match(storedMemories[0].content, /User clicked button/); + + // Verify telemetry + const ingestedEvent = telemetryEvents.find((e) => e.event === 'memory:ingested'); + assert.ok(ingestedEvent, 'Should emit memory:ingested telemetry'); + assert.equal(ingestedEvent.payload.agent_id, 'agent-1'); + assert.equal(ingestedEvent.payload.chunk_events, 2); + + // Verify metrics + const ingestedMetric = metricsEvents.find((e) => e.metric === 'memory_ingested_total'); + assert.ok(ingestedMetric, 'Should emit memory_ingested_total metric'); + assert.equal(ingestedMetric.payload.count, 1); + + // Verify embedding link + assert.ok(linkedEmbeddings.has(storedMemories[0].id), 'Should link embedding'); + }); + + test('skips ingestion when feature flag is disabled', async () => { + const events = [{ agent_id: 'agent-1', content: 'test', timestamp: Date.now() }]; + const options = { + ...mockOptions, + repository, + env: { TORCH_MEMORY_INGEST_ENABLED: 'false' }, + }; + + const result = await ingestEvents(events, options); + + assert.equal(result.length, 0); + assert.equal(storedMemories.length, 0); + + const skippedEvent = telemetryEvents.find((e) => e.event === 'memory:ingest_skipped'); + assert.ok(skippedEvent, 'Should emit memory:ingest_skipped telemetry'); + assert.equal(skippedEvent.payload.reason, 'flag_disabled'); + }); + + test('clears cache after ingestion', async () => { + const agentId = 'agent-cache-test'; + + // 1. Populate cache + await getRelevantMemories({ agent_id: agentId, repository, ...mockOptions }); + + // 2. Verify cache hit (by checking telemetry from a second call) + telemetryEvents = []; // Reset telemetry + await getRelevantMemories({ agent_id: agentId, repository, ...mockOptions }); + const hitEvent = telemetryEvents.find(e => e.event === 'memory:retrieved'); + assert.ok(hitEvent.payload.cache_hit, 'Should hit cache before ingestion'); + + // 3. Ingest event + await ingestEvents( + [{ agent_id: agentId, content: 'new event', timestamp: mockOptions.now }], + { ...mockOptions, repository } + ); + + // 4. Verify cache miss + telemetryEvents = []; // Reset telemetry + await getRelevantMemories({ agent_id: agentId, repository, ...mockOptions }); + const missEvent = telemetryEvents.find(e => e.event === 'memory:retrieved'); + assert.equal(missEvent.payload.cache_hit, false, 'Should miss cache after ingestion (cache cleared)'); + }); + + test('skips duplicate windows', async () => { + const events = [{ agent_id: 'agent-dedupe', content: 'repeat', timestamp: mockOptions.now }]; + + // First ingestion + await ingestEvents(events, { ...mockOptions, repository }); + assert.equal(storedMemories.length, 1); + + // Second ingestion (should be duplicate) + await ingestEvents(events, { ...mockOptions, repository }); + assert.equal(storedMemories.length, 1, 'Should not insert duplicate memory'); + }); + + test('propagates repository errors', async () => { + const errorRepository = { + ...repository, + async insertMemory() { + throw new Error('Database connection failed'); + }, + }; + + const events = [{ agent_id: 'agent-err', content: 'fail', timestamp: mockOptions.now }]; + + await assert.rejects( + async () => ingestEvents(events, { ...mockOptions, repository: errorRepository }), + /Database connection failed/ + ); + }); +}); diff --git a/test/memory-run-prune-cycle.test.mjs b/test/memory-run-prune-cycle.test.mjs new file mode 100644 index 0000000..a9092a3 --- /dev/null +++ b/test/memory-run-prune-cycle.test.mjs @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; + +const originalCwd = process.cwd(); +let tmpDir; + +function createMemory(overrides = {}) { + return { + schema_version: 1, + id: overrides.id ?? crypto.randomUUID(), + agent_id: overrides.agent_id ?? 'agent-1', + session_id: overrides.session_id ?? 's1', + type: overrides.type ?? 'event', + content: overrides.content ?? 'test content', + summary: overrides.summary ?? 'test summary', + tags: overrides.tags ?? [], + importance: overrides.importance ?? 0.5, + embedding_id: overrides.embedding_id ?? null, + created_at: overrides.created_at ?? Date.now(), + last_seen: overrides.last_seen ?? Date.now(), + source: overrides.source ?? 'ingest', + ttl_days: overrides.ttl_days ?? null, + merged_into: overrides.merged_into ?? null, + pinned: overrides.pinned ?? false, + }; +} + +// Global state variables for the module under test +let runPruneCycle; +let listMemories; + +// Setup temp environment and load module +test.before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-prune-test-')); + + // Create .scheduler-memory directory + const memDir = path.join(tmpDir, '.scheduler-memory'); + await fs.mkdir(memDir, { recursive: true }); + + // Create initial state + const now = 200_000; + // retentionMs will be 100,000 + // cutoff = 100,000 + + const memories = [ + createMemory({ id: 'recent', last_seen: 150_000 }), // > 100,000 -> Keep + createMemory({ id: 'pinned', last_seen: 50_000, pinned: true }), // < 100,000 but pinned -> Keep + createMemory({ id: 'stale', last_seen: 50_000 }), // < 100,000 -> Delete + createMemory({ id: 'stale-dry', last_seen: 50_000 }), // < 100,000 -> Delete candidate + createMemory({ id: 'stale-off', last_seen: 50_000 }), // < 100,000 -> Delete candidate + ]; + + // memory-store.json expects an array of entries [[id, memory], ...] + const entries = memories.map(m => [m.id, m]); + await fs.writeFile(path.join(memDir, 'memory-store.json'), JSON.stringify(entries), 'utf8'); + + // Change CWD + process.chdir(tmpDir); + + // Import module (it will load the file we just wrote) + const mod = await import('../src/services/memory/index.js'); + runPruneCycle = mod.runPruneCycle; + listMemories = mod.listMemories; +}); + +test.after(async () => { + if (originalCwd) process.chdir(originalCwd); + if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +test('runPruneCycle respects off mode', async () => { + const now = 200_000; + const retentionMs = 100_000; + + const result = await runPruneCycle({ + now, + retentionMs, + pruneMode: 'off', + }); + + assert.equal(result.mode, 'off'); + + // Verify nothing deleted + const memories = await listMemories(); + assert.equal(memories.length, 5); +}); + +test('runPruneCycle respects dry-run mode', async () => { + const now = 200_000; + const retentionMs = 100_000; + + const result = await runPruneCycle({ + now, + retentionMs, + pruneMode: 'dry-run', + }); + + assert.equal(result.mode, 'dry-run'); + // Expect 3 candidates: stale, stale-dry, stale-off + assert.equal(result.candidates.length, 3); + + // Verify nothing deleted + const memories = await listMemories(); + assert.equal(memories.length, 5); +}); + +test('runPruneCycle removes stale unpinned memories in active mode', async () => { + const now = 200_000; + const retentionMs = 100_000; + + const telemetry = []; + const metrics = []; + + const result = await runPruneCycle({ + now, + retentionMs, + pruneMode: 'active', + emitTelemetry: (event, payload) => telemetry.push({ event, payload }), + emitMetric: (metric, payload) => metrics.push({ metric, payload }), + }); + + assert.equal(result.pruned.length, 3); // stale, stale-dry, stale-off + + // Verify deletion + const memories = await listMemories(); + assert.equal(memories.length, 2); + const ids = memories.map(m => m.id); + assert.ok(ids.includes('recent')); + assert.ok(ids.includes('pinned')); + assert.ok(!ids.includes('stale')); + + // Verify telemetry + const prunedEvent = telemetry.find(t => t.event === 'memory:pruned'); + assert.ok(prunedEvent); + assert.equal(prunedEvent.payload.pruned_count, 3); + + // Verify metrics + const prunedMetric = metrics.find(m => m.metric === 'memory_pruned_total'); + assert.ok(prunedMetric); + assert.equal(prunedMetric.payload.count, 3); +}); + +test('runPruneCycle starts scheduler if requested', async () => { + const result = await runPruneCycle({ + scheduleEveryMs: 1000, + pruneMode: 'active', + }); + + assert.ok(result.scheduler); + assert.equal(typeof result.scheduler.stop, 'function'); + result.scheduler.stop(); +}); diff --git a/test/memory-stats.test.mjs b/test/memory-stats.test.mjs new file mode 100644 index 0000000..b74e559 --- /dev/null +++ b/test/memory-stats.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + memoryStats, + ingestEvents, + runPruneCycle, +} from '../src/services/memory/index.js'; + +test('memoryStats with empty repository returns zero counts and rates', async () => { + const repository = { + async listMemories() { + return []; + }, + }; + const stats = await memoryStats({ repository }); + + assert.deepEqual(stats.countsByType, {}); + assert.equal(stats.totals.total, 0); + assert.equal(stats.totals.pinned, 0); + assert.equal(stats.totals.archived, 0); + assert.equal(stats.rates.archivedRate, 0); + assert.ok(Number.isFinite(stats.rates.deletedRate)); + assert.equal(stats.indexSizeEstimateBytes, 0); + assert.ok(Number.isFinite(stats.ingestThroughputPerMinute)); +}); + +test('memoryStats with populated repository returns correct counts and totals', async () => { + const memories = [ + { type: 'event', pinned: false, merged_into: null }, + { type: 'event', pinned: true, merged_into: null }, + { type: 'summary', pinned: false, merged_into: 'some-id' }, + ]; + const repository = { + async listMemories() { + return memories; + }, + }; + + const stats = await memoryStats({ repository }); + + assert.equal(stats.countsByType.event, 2); + assert.equal(stats.countsByType.summary, 1); + assert.equal(stats.totals.total, 3); + assert.equal(stats.totals.pinned, 1); + assert.equal(stats.totals.archived, 1); + + // archivedRate = archived / max(1, total) = 1 / 3 + assert.equal(stats.rates.archivedRate, 1 / 3); + + // indexSizeEstimateBytes = 3 * 512 = 1536 + assert.equal(stats.indexSizeEstimateBytes, 1536); +}); + +test('memoryStats calculates ingest throughput based on window', async (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const baseTime = 1000000; + t.mock.timers.setTime(baseTime); + + const repository = { + async insertMemory() { return {}; }, + async hasIngestionFingerprint() { return false; }, + async storeIngestionFingerprint() { }, + async linkEmbedding() { }, + async listMemories() { return []; }, + }; + + // Create 3 distinct records (different agents to avoid batching into one record) + await ingestEvents([ + { content: 'e1', agent_id: 'agent-1', timestamp: baseTime }, + { content: 'e2', agent_id: 'agent-2', timestamp: baseTime }, + { content: 'e3', agent_id: 'agent-3', timestamp: baseTime }, + ], { repository }); + + // Move time forward 30 mins + const checkTime = baseTime + 30 * 60 * 1000; + t.mock.timers.setTime(checkTime); + + // Check stats with 60 min window (should include baseTime) + // Window: [checkTime - 60min, checkTime] = [baseTime - 30min, baseTime + 30min] + const stats1 = await memoryStats({ + repository, + now: checkTime, + windowMs: 60 * 60 * 1000 + }); + + // We expect 3 ingested items in this window. + // throughput = 3 / (60 mins) = 0.05 + assert.equal(stats1.ingestThroughputPerMinute, 3 / 60); + + // Check stats with window that excludes the events (checkTime + 90 mins) + const farTime = baseTime + 90 * 60 * 1000; + // Window: [farTime - 60min, farTime] = [baseTime + 30min, baseTime + 90min] + // Events at baseTime are < baseTime + 30min, so excluded. + + const stats2 = await memoryStats({ + repository, + now: farTime, + windowMs: 60 * 60 * 1000 + }); + + assert.equal(stats2.ingestThroughputPerMinute, 0); +}); + +test('memoryStats reflects deleted count from prune cycle', async () => { + const repository = { + async listPruneCandidates() { + // Return 2 candidates to be pruned + return [ + { id: 'p1', agent_id: 'a', type: 'event', last_seen: 0, pinned: false }, + { id: 'p2', agent_id: 'a', type: 'event', last_seen: 0, pinned: false }, + ]; + }, + async listMemories() { return []; } + }; + + // Run prune cycle + await runPruneCycle({ repository, retentionMs: 100, now: 1000 }); + + const stats = await memoryStats({ repository }); + + // deletedObserved should be increased by 2. + assert.ok(stats.totals.deletedObserved >= 2); + + // deletedRate should be > 0. + assert.ok(stats.rates.deletedRate > 0); +}); + +test('memoryStats handles windowMs parameter correctly', async () => { + const repository = { async listMemories() { return []; } }; + + // Test with invalid windowMs, should default to 1 hour (60 mins) + const statsDefault = await memoryStats({ repository, windowMs: 'invalid' }); + assert.ok(Number.isFinite(statsDefault.ingestThroughputPerMinute)); + + // Test with very small window + const statsSmall = await memoryStats({ repository, windowMs: 1 }); + assert.ok(Number.isFinite(statsSmall.ingestThroughputPerMinute)); +}); diff --git a/test/torch-config-unit.test.mjs b/test/torch-config-unit.test.mjs index 5c41d11..95b4913 100644 --- a/test/torch-config-unit.test.mjs +++ b/test/torch-config-unit.test.mjs @@ -7,83 +7,96 @@ describe('loadTorchConfig (Unit Tests with Mocked FS)', () => { _resetTorchConfigCache(); }); - it('loads valid config correctly', () => { + it('loads valid config correctly', async () => { const mockFs = { existsSync: mock.fn(() => true), - readFileSync: mock.fn(() => JSON.stringify({ - nostrLock: { namespace: 'unit-test-ns' } - })) + promises: { + readFile: mock.fn(async () => JSON.stringify({ + nostrLock: { namespace: 'unit-test-ns' } + })) + } }; - const config = loadTorchConfig(mockFs); + const config = await loadTorchConfig(mockFs); assert.strictEqual(config.nostrLock.namespace, 'unit-test-ns'); - assert.strictEqual(mockFs.existsSync.mock.calls.length, 1); - assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); + // loadTorchConfig no longer calls existsSync on the passed fs, only readFile (and catches ENOENT) + // Actually, getTorchConfigPath calls existsSync on REAL fs. + // So checking mockFs.existsSync is wrong if loadTorchConfig doesn't call it. + // In my change, loadTorchConfig calls await fileSystem.promises.readFile. + assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1); }); - it('returns default config when file is missing', () => { + it('returns default config when file is missing', async () => { const mockFs = { - existsSync: mock.fn(() => false), - readFileSync: mock.fn() + promises: { + readFile: mock.fn(async () => { + const err = new Error('ENOENT'); + err.code = 'ENOENT'; + throw err; + }) + } }; - const config = loadTorchConfig(mockFs); + const config = await loadTorchConfig(mockFs); assert.strictEqual(config.nostrLock.namespace, null); - assert.strictEqual(mockFs.existsSync.mock.calls.length, 1); - assert.strictEqual(mockFs.readFileSync.mock.calls.length, 0); + assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1); }); - it('throws error on malformed JSON', () => { + it('throws error on malformed JSON', async () => { const mockFs = { - existsSync: mock.fn(() => true), - readFileSync: mock.fn(() => '{ invalid json }') + promises: { + readFile: mock.fn(async () => '{ invalid json }') + } }; - assert.throws(() => { - loadTorchConfig(mockFs); + await assert.rejects(async () => { + await loadTorchConfig(mockFs); }, /Failed to parse/); }); - it('caches the config and ignores subsequent fs calls', () => { + it('caches the config and ignores subsequent fs calls', async () => { const mockFs = { - existsSync: mock.fn(() => true), - readFileSync: mock.fn(() => JSON.stringify({ nostrLock: { namespace: 'cached' } })) + promises: { + readFile: mock.fn(async () => JSON.stringify({ nostrLock: { namespace: 'cached' } })) + } }; - const config1 = loadTorchConfig(mockFs); + const config1 = await loadTorchConfig(mockFs); assert.strictEqual(config1.nostrLock.namespace, 'cached'); // Second call with a different "fs" (or same) should not trigger fs operations const mockFs2 = { - existsSync: mock.fn(() => true), - readFileSync: mock.fn(() => JSON.stringify({ nostrLock: { namespace: 'changed' } })) + promises: { + readFile: mock.fn(async () => JSON.stringify({ nostrLock: { namespace: 'changed' } })) + } }; - const config2 = loadTorchConfig(mockFs2); + const config2 = await loadTorchConfig(mockFs2); assert.strictEqual(config2.nostrLock.namespace, 'cached'); assert.strictEqual(config1, config2); - assert.strictEqual(mockFs.existsSync.mock.calls.length, 1); - assert.strictEqual(mockFs2.existsSync.mock.calls.length, 0); + assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1); + assert.strictEqual(mockFs2.promises.readFile.mock.calls.length, 0); }); - it('returns null for empty string lists (new consistent behavior)', () => { + it('returns null for empty string lists (new consistent behavior)', async () => { const mockFs = { - existsSync: mock.fn(() => true), - readFileSync: mock.fn(() => JSON.stringify({ - nostrLock: { - relays: [], - relayFallbacks: [] - }, - dashboard: { - relays: [] - } - })) + promises: { + readFile: mock.fn(async () => JSON.stringify({ + nostrLock: { + relays: [], + relayFallbacks: [] + }, + dashboard: { + relays: [] + } + })) + } }; - const config = loadTorchConfig(mockFs); + const config = await loadTorchConfig(mockFs); // New behavior: all return null assert.strictEqual(config.nostrLock.relays, null); diff --git a/test/torch-config.test.mjs b/test/torch-config.test.mjs index 190cf84..2ea4728 100644 --- a/test/torch-config.test.mjs +++ b/test/torch-config.test.mjs @@ -78,22 +78,22 @@ describe('torch-config', () => { _resetTorchConfigCache(); }); - it('throws fatal error for invalid relay URL', () => { + it('throws fatal error for invalid relay URL', async () => { fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { relays: ['https://bad'] } })); process.env.TORCH_CONFIG_PATH = tempConfigPath; - assert.throws(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/); + await assert.rejects(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/); }); - it('throws fatal error for invalid timeout range', () => { + it('throws fatal error for invalid timeout range', async () => { fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { queryTimeoutMs: 50 } })); process.env.TORCH_CONFIG_PATH = tempConfigPath; - assert.throws(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/); + await assert.rejects(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/); }); - it('throws error for malformed JSON', () => { + it('throws error for malformed JSON', async () => { fs.writeFileSync(badConfigPath, '{ invalid json }'); process.env.TORCH_CONFIG_PATH = badConfigPath; - assert.throws(() => loadTorchConfig(), /Failed to parse/); + await assert.rejects(() => loadTorchConfig(), /Failed to parse/); }); }); @@ -125,28 +125,28 @@ describe('torch-config', () => { _resetTorchConfigCache(); }); - it('returns default relays', () => { - assert.deepStrictEqual(getRelays(), DEFAULT_RELAYS); + it('returns default relays', async () => { + assert.deepStrictEqual(await getRelays(), DEFAULT_RELAYS); }); - it('validates env knob values', () => { + it('validates env knob values', async () => { process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '99'; - assert.throws(() => getQueryTimeoutMs(), /NOSTR_LOCK_QUERY_TIMEOUT_MS/); + await assert.rejects(() => 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/); + await assert.rejects(() => 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/); + await assert.rejects(() => getMinSuccessfulRelayPublishes(), /NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES/); process.env.NOSTR_LOCK_RELAY_FALLBACKS = 'https://bad'; - assert.throws(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/); + await assert.rejects(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/); process.env.NOSTR_LOCK_RELAY_FALLBACKS = ''; process.env.NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL = '0'; - assert.throws(() => getMinActiveRelayPool(), /NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL/); + await assert.rejects(() => getMinActiveRelayPool(), /NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL/); }); }); });