Merge branch 'main' into jules/testing-relay-health-12520607155456440581

This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-18 09:44:14 -05:00
commit a231ea0784
133 files changed
+11210 -2781

No files matched your search

+187
View File
@@ -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, []);
});
});
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { cmdDashboard } from '../src/dashboard.mjs';
const tempConfigPath = path.resolve('torch-config-exposure-test.json');
const SENSITIVE_CONFIG = {
nostrLock: {
namespace: 'test-namespace',
relays: ['wss://relay.example.com'],
dailyRoster: ['user1', 'user2'], // Sensitive roster
weeklyRoster: ['user3'],
ttlSeconds: 3600,
queryTimeoutMs: 5000,
publishTimeoutMs: 5000,
minSuccessfulRelayPublishes: 2,
minActiveRelayPool: 1,
},
dashboard: {
auth: 'secret-auth-string', // stripped by auth check
relays: ['wss://dashboard.relay.example.com'],
},
scheduler: {
paused: {
daily: ['user1'],
},
firstPromptByCadence: {
daily: 'some prompt',
},
},
};
test('Dashboard Configuration Exposure Security', async (t) => {
// Setup temp config
fs.writeFileSync(tempConfigPath, JSON.stringify(SENSITIVE_CONFIG, null, 2));
// Override env to use this config
const originalEnvPath = process.env.TORCH_CONFIG_PATH;
const originalAuth = process.env.TORCH_DASHBOARD_AUTH;
process.env.TORCH_CONFIG_PATH = tempConfigPath;
// Disable auth via env so we can access the endpoint without credentials
// The vulnerability is about exposure of *other* fields.
// We need to bypass the basic auth middleware to test the JSON response.
// However, cmdDashboard checks config.dashboard.auth too.
// If we want to test that auth is stripped from JSON, we should enable auth but provide credentials.
// OR we can just test the sanitization logic which runs regardless of how we got there?
// No, the endpoint logic runs after auth check.
// To keep it simple: we want to verify that IF we can access the endpoint, the sensitive data is gone.
// We can simulate an authorized request or disable auth.
// If we disable auth in config, we can access it.
// Let's modify the config for the test to NOT have auth, so we can access it freely.
// We want to verify that things like 'dailyRoster' are stripped.
const configForTest = { ...SENSITIVE_CONFIG };
delete configForTest.dashboard.auth;
fs.writeFileSync(tempConfigPath, JSON.stringify(configForTest, null, 2));
let server;
let serverPort;
// Cleanup function
t.after(() => {
if (server) server.close();
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
if (originalEnvPath) process.env.TORCH_CONFIG_PATH = originalEnvPath;
else delete process.env.TORCH_CONFIG_PATH;
});
// Start server
await new Promise((resolve) => {
cmdDashboard(0).then((srv) => {
server = srv;
serverPort = server.address().port;
resolve();
});
});
// Fetch config
const responseBody = await new Promise((resolve, reject) => {
http.get(`http://localhost:${serverPort}/torch-config.json`, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(data));
}).on('error', reject);
});
let exposedConfig;
try {
exposedConfig = JSON.parse(responseBody);
} catch (e) {
assert.fail(`Failed to parse JSON response: ${responseBody}`);
}
// 1. Assert sensitive fields are NOT present
assert.equal(exposedConfig.nostrLock?.dailyRoster, undefined, 'dailyRoster should NOT be exposed');
assert.equal(exposedConfig.nostrLock?.weeklyRoster, undefined, 'weeklyRoster should NOT be exposed');
assert.equal(exposedConfig.nostrLock?.relayFallbacks, undefined, 'relayFallbacks should NOT be exposed');
assert.equal(exposedConfig.scheduler, undefined, 'scheduler config should NOT be exposed');
assert.equal(exposedConfig.raw, undefined, 'raw config should NOT be exposed');
// 2. Assert required fields ARE present
assert.ok(exposedConfig.dashboard, 'dashboard config should be present');
// Note: cmdDashboard loads from file, parseTorchConfig normalizes keys.
// check dashboard.relays
assert.deepEqual(exposedConfig.dashboard.relays, ['wss://dashboard.relay.example.com'], 'dashboard relays should be present');
assert.ok(exposedConfig.nostrLock, 'nostrLock config should be present');
assert.equal(exposedConfig.nostrLock.namespace, 'test-namespace', 'nostrLock namespace should be present');
assert.deepEqual(exposedConfig.nostrLock.relays, ['wss://relay.example.com'], 'nostrLock relays should be present');
});
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { cmdDashboard } from '../src/dashboard.mjs';
test('Dashboard Path Traversal Security Fix', async (t) => {
let testPort = 0;
let server;
t.beforeEach(async () => {
// Start dashboard on random port
server = await cmdDashboard(0);
testPort = server.address().port;
});
t.afterEach((done) => {
if (server) server.close(done);
else done();
});
function get(path) {
return new Promise((resolve, reject) => {
const req = http.get({
host: '127.0.0.1',
port: testPort,
path: path
}, (res) => {
// Consume data to avoid memory leaks
res.resume();
resolve(res);
});
req.on('error', reject);
});
}
// --- Valid Access ---
await t.test('allows access to valid dashboard index', async () => {
const res = await get('/dashboard/index.html');
assert.strictEqual(res.statusCode, 200, 'Should allow access to dashboard index');
});
// --- Traversal Attempts ---
const traversalPayloads = [
'/../../etc/passwd',
'/dashboard/../../etc/passwd',
'/dashboard/../../../etc/passwd',
'/../package.json',
'/dashboard/../package.json',
'/dashboard/%2e%2e/package.json', // URL Encoded ..
'/dashboard/%2e%2e%2fpackage.json', // URL Encoded ../
'/..%2fpackage.json',
'/%2e%2e%2fpackage.json',
'/dashboard/..%5cpackage.json', // Encoded backslash
'/dashboard/..%252fpackage.json', // Double encoded
'/dashboard/....//package.json', // Weird dots
'/dashboard/.../package.json', // Three dots
'//etc/passwd', // Double slash
'/dashboard//../../etc/passwd',
];
for (const payload of traversalPayloads) {
await t.test(`blocks traversal attempt: ${payload}`, async () => {
const res = await get(payload);
// Depending on how it's resolved, it might be 403 (Forbidden) or 404 (Not Found)
// If it escapes allowed paths, it should be 403.
// If it stays within allowed paths but file doesn't exist, 404.
// But we want to ensure it doesn't return 200 for sensitive files if they existed.
// Since we don't know if /etc/passwd exists on test environment, we assume 403 or 404 is safe.
// However, for package.json (which exists in root), it MUST be 403 because it's not in allowed paths.
// If the payload targets a file outside allowed paths (like package.json), it MUST be 403.
// If the payload targets a file that doesn't exist but is outside allowed paths, it MUST be 403.
// The current logic returns 403 if not in allowedPaths.
assert.notStrictEqual(res.statusCode, 200, `Should not return 200 for ${payload}`);
// If it targets package.json, we expect 403 because it exists but is restricted.
// EXCEPTION: If the payload uses encoded slashes (%2f) that are NOT decoded by URL parser,
// it is treated as a filename, not a separator. Thus it looks for a file with %2f in name,
// which doesn't exist -> 404. This is SAFE.
// So for package.json payloads, we accept 403 (blocked traversal) or 404 (failed traversal/not found).
if (payload.includes('package.json')) {
assert.ok([403, 404].includes(res.statusCode), `Should return 403 or 404 for ${payload}, got ${res.statusCode}`);
}
});
}
// --- Allowed Traversal (within allowed scope) ---
await t.test('allows traversal within allowed paths', async () => {
// /dashboard/../dashboard/index.html -> /dashboard/index.html (Allowed)
const res = await get('/dashboard/../dashboard/index.html');
assert.strictEqual(res.statusCode, 200, 'Should allow traversal that stays within allowed paths');
});
});
+6 -4
View File
@@ -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) {
+84
View File
@@ -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();
});
});
});
+246
View File
@@ -193,3 +193,249 @@ test('renderPromptWithMemoryContext handles missing query or service', async ()
const output = await renderPromptWithMemoryContext(params);
assert.equal(output, 'Base prompt');
});
test('renderPromptWithMemoryContext handles circular agentContext gracefully', async () => {
const circular = {};
circular.self = circular;
let capturedQuery = '';
const memoryService = {
async getRelevantMemories({ query }) {
capturedQuery = query;
return [];
},
async updateMemoryUsage() {}
};
const params = {
basePrompt: 'Base prompt',
userRequest: 'Hello',
agentContext: circular,
agent_id: 'agent-1',
memoryService,
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
};
const output = await renderPromptWithMemoryContext(params);
// It should not throw.
// The query should contain the string representation of the circular object.
// String(circular) is "[object Object]"
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/);
});
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getRelevantMemories } from '../src/services/memory/index.js';
function createMockMemory(id) {
return {
id,
content: `content for ${id}`,
agent_id: 'agent-1',
created_at: Date.now(),
last_seen: Date.now(),
};
}
test('getRelevantMemories uses cached results when available', async (t) => {
const cachedMemory = createMockMemory('cached-1');
const cacheGetMock = t.mock.fn((key) => {
if (key.includes('test-query')) return [cachedMemory];
return null;
});
const cacheSetMock = t.mock.fn();
const mockCache = {
get: cacheGetMock,
set: cacheSetMock,
};
const repository = {
listMemories: t.mock.fn(async () => []),
updateMemoryUsage: async () => {},
};
const result = await getRelevantMemories({
agent_id: 'agent-1',
query: 'test-query',
cache: mockCache,
repository,
});
assert.equal(result.length, 1);
assert.equal(result[0].id, 'cached-1');
assert.equal(cacheGetMock.mock.callCount(), 1);
// Should NOT call listMemories on cache hit
assert.equal(repository.listMemories.mock.callCount(), 0);
// Should NOT call cache.set on cache hit
assert.equal(cacheSetMock.mock.callCount(), 0);
});
test('getRelevantMemories calls ranker and updates cache on miss', async (t) => {
const memories = [createMockMemory('m1'), createMockMemory('m2')];
const repository = {
listMemories: t.mock.fn(async () => memories),
updateMemoryUsage: t.mock.fn(async () => {}),
};
// Mock ranker to reverse the list
const ranker = t.mock.fn(async (source) => [...source].reverse());
const cacheSetMock = t.mock.fn();
const mockCache = {
get: () => null,
set: cacheSetMock,
};
const result = await getRelevantMemories({
agent_id: 'agent-1',
query: 'test-query',
repository,
ranker,
cache: mockCache,
});
assert.equal(result.length, 2);
assert.equal(result[0].id, 'm2');
assert.equal(result[1].id, 'm1');
// Verify ranker called with correct source
assert.equal(ranker.mock.callCount(), 1);
const [sourceArg] = ranker.mock.calls[0].arguments;
assert.deepEqual(sourceArg, memories);
// Verify cache updated
assert.equal(cacheSetMock.mock.callCount(), 1);
const [keyArg, valueArg] = cacheSetMock.mock.calls[0].arguments;
assert.ok(typeof keyArg === 'string');
assert.deepEqual(valueArg, result); // Should cache the ranked result
// Verify memory usage updated
assert.equal(repository.updateMemoryUsage.mock.callCount(), 2);
});
test('getRelevantMemories passes correct parameters to ranker', async (t) => {
const repository = {
listMemories: async () => [],
updateMemoryUsage: async () => {},
};
const ranker = t.mock.fn(async () => []);
const mockCache = { get: () => null, set: () => {} };
const params = {
agent_id: 'agent-1',
query: 'my query',
tags: ['a', 'b'],
k: 5,
repository,
ranker,
cache: mockCache,
};
await getRelevantMemories(params);
assert.equal(ranker.mock.callCount(), 1);
const [_, queryParams] = ranker.mock.calls[0].arguments;
assert.equal(queryParams.agent_id, 'agent-1');
assert.equal(queryParams.query, 'my query');
assert.deepEqual(queryParams.tags, ['a', 'b']);
assert.equal(queryParams.k, 5);
// Ensure injected deps are NOT in queryParams
assert.equal(queryParams.repository, undefined);
assert.equal(queryParams.ranker, undefined);
assert.equal(queryParams.cache, undefined);
});
test('getRelevantMemories triggers updateMemoryUsage on repository', async (t) => {
const m1 = createMockMemory('m1');
const repository = {
listMemories: async () => [m1],
updateMemoryUsage: t.mock.fn(async () => {}),
};
const ranker = async (source) => source; // identity ranker
const mockCache = { get: () => null, set: () => {} };
await getRelevantMemories({
agent_id: 'agent-1',
repository,
ranker,
cache: mockCache,
});
// updateMemoryUsage helper calls repository.updateMemoryUsage for each memory
assert.equal(repository.updateMemoryUsage.mock.callCount(), 1);
const [idArg] = repository.updateMemoryUsage.mock.calls[0].arguments;
assert.equal(idArg, 'm1');
});
+165
View File
@@ -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/
);
});
});
+70 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getRelevantMemories, pinMemory } from '../src/services/memory/index.js';
import { getRelevantMemories, pinMemory, unpinMemory } from '../src/services/memory/index.js';
test('pinMemory pins the memory and clears the cache', async () => {
const records = [
@@ -70,3 +70,72 @@ test('pinMemory returns null for non-existent ID but still clears cache', async
await getRelevantMemories({ agent_id: 'agent-b', repository });
assert.equal(listMemoriesCallCount, 2, 'Cache should be cleared even if ID not found');
});
test('unpinMemory unpins the memory and clears the cache', async () => {
const records = [
{ id: 'm1', agent_id: 'agent-c', type: 'event', tags: ['ops'], pinned: true, last_seen: 10, merged_into: null },
];
let listMemoriesCallCount = 0;
const repository = {
async listMemories() {
listMemoriesCallCount++;
return records;
},
async getMemoryById(id) {
return records.find((record) => record.id === id) ?? null;
},
async setPinned(id, pinned) {
const index = records.findIndex((record) => record.id === id);
if (index < 0) return null;
records[index] = { ...records[index], pinned };
return records[index];
},
async updateMemoryUsage() { return null; }
};
// 1. Populate cache
await getRelevantMemories({ agent_id: 'agent-c', repository });
assert.equal(listMemoriesCallCount, 1, 'Repository should be queried initially');
// 2. Verify cache hit
await getRelevantMemories({ agent_id: 'agent-c', repository });
assert.equal(listMemoriesCallCount, 1, 'Repository should NOT be queried on cache hit');
// 3. Unpin memory
const updated = await unpinMemory('m1', { repository });
assert.ok(updated);
assert.equal(updated.pinned, false);
assert.equal(records[0].pinned, false);
// 4. Verify cache miss (cleared)
await getRelevantMemories({ agent_id: 'agent-c', repository });
assert.equal(listMemoriesCallCount, 2, 'Repository should be queried again after cache clear');
});
test('unpinMemory returns null for non-existent ID but still clears cache', async () => {
const records = [];
let listMemoriesCallCount = 0;
const repository = {
async listMemories() {
listMemoriesCallCount++;
return records;
},
async setPinned(id, pinned) {
return null;
},
async updateMemoryUsage() { return null; }
};
// 1. Populate cache (use different agent_id to avoid cache collision with previous test)
await getRelevantMemories({ agent_id: 'agent-d', repository });
assert.equal(listMemoriesCallCount, 1);
// 2. Unpin non-existent memory
const result = await unpinMemory('non-existent', { repository });
assert.equal(result, null);
// 3. Verify cache cleared
await getRelevantMemories({ agent_id: 'agent-d', repository });
assert.equal(listMemoriesCallCount, 2, 'Cache should be cleared even if ID not found');
});
+15 -4
View File
@@ -138,11 +138,22 @@ test('applyLifecycleActions executes keep/archive/delete and merge markers', asy
policyLogs: [],
});
assert.deepEqual(calls, [
['keepMemory', 'k1', 'keep reason'],
['markMerged', 'a1', 'k1'],
// Sort calls to verify presence regardless of execution order
const sortedCalls = [...calls].sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
const expectedCalls = [
['archiveMemory', 'a1', 'archive reason'],
['deleteMemory', 'd1', 'delete reason'],
]);
['keepMemory', 'k1', 'keep reason'],
['markMerged', 'a1', 'k1'],
];
assert.deepEqual(sortedCalls, expectedCalls);
// Verify that markMerged happens before archiveMemory for the same ID
const markIndex = calls.findIndex((c) => c[0] === 'markMerged' && c[1] === 'a1');
const archiveIndex = calls.findIndex((c) => c[0] === 'archiveMemory' && c[1] === 'a1');
assert.ok(markIndex !== -1, 'markMerged was called');
assert.ok(archiveIndex !== -1, 'archiveMemory was called');
assert.ok(markIndex < archiveIndex, 'markMerged should precede archiveMemory');
assert.equal(result.applied.length, 3);
});
+156
View File
@@ -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();
});
+138
View File
@@ -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));
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { RelayHealthManager } from '../src/lock-ops.mjs';
describe('RelayHealthManager', () => {
it('should isolate state between instances', () => {
const manager1 = new RelayHealthManager();
const manager2 = new RelayHealthManager();
const config = {
rollingWindowSize: 5,
failureThreshold: 3,
quarantineCooldownMs: 1000,
maxQuarantineCooldownMs: 5000,
};
manager1.recordOutcome('wss://relay1', true, null, 100, config);
// Manager 1 should have metrics
const metrics1 = manager1.ensureMetrics('wss://relay1', config);
assert.strictEqual(metrics1.recentOutcomes.length, 1);
// Manager 2 should have empty metrics for relay1
const metrics2 = manager2.ensureMetrics('wss://relay1', config);
assert.strictEqual(metrics2.recentOutcomes.length, 0);
});
it('should prioritize relays based on health', () => {
const manager = new RelayHealthManager();
const config = {
rollingWindowSize: 5,
failureThreshold: 2,
quarantineCooldownMs: 1000,
maxQuarantineCooldownMs: 5000,
minActiveRelayPool: 1,
};
// Relay 1: Healthy
manager.recordOutcome('wss://healthy', true, null, 100, config);
// Relay 2: Failing
manager.recordOutcome('wss://failing', false, 'error', 100, config);
manager.recordOutcome('wss://failing', false, 'error', 100, config); // Quarantined
const result = manager.prioritizeRelays(['wss://healthy', 'wss://failing'], config);
assert.deepStrictEqual(result.prioritized, ['wss://healthy']);
// Check ranking
const healthyRank = result.ranked.find(r => r.relay === 'wss://healthy');
const failingRank = result.ranked.find(r => r.relay === 'wss://failing');
assert.ok(healthyRank.score > failingRank.score);
assert.strictEqual(failingRank.summary.quarantined, true);
});
it('should reset state correctly', () => {
const manager = new RelayHealthManager();
const config = {
rollingWindowSize: 5,
failureThreshold: 3,
quarantineCooldownMs: 1000,
maxQuarantineCooldownMs: 5000,
};
manager.recordOutcome('wss://relay1', true, null, 100, config);
assert.strictEqual(manager.metricsByRelay.size, 1);
manager.reset();
assert.strictEqual(manager.metricsByRelay.size, 0);
});
it('should cache ranked results and invalidate on outcome', () => {
const manager = new RelayHealthManager();
const config = {
rollingWindowSize: 5,
failureThreshold: 2,
quarantineCooldownMs: 1000,
maxQuarantineCooldownMs: 5000,
minActiveRelayPool: 1,
};
const now = Date.now();
manager.recordOutcome('wss://r1', true, null, 100, config, now);
manager.recordOutcome('wss://r2', true, null, 200, config, now);
const result1 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now);
// Internal cache should be populated
assert.ok(manager._sortedCache);
assert.strictEqual(manager._sortedCache.version, manager._metricsVersion);
const result2 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now);
// Should be same objects (deep equality check on content, but references might differ due to map)
// Actually rankRelays returns new objects always.
assert.deepStrictEqual(result1, result2);
// Modify outcome
manager.recordOutcome('wss://r1', false, 'timeout', 1000, config, now);
// Version should increase
assert.ok(manager._sortedCache.version < manager._metricsVersion);
const result3 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now);
// Cache should be updated
assert.strictEqual(manager._sortedCache.version, manager._metricsVersion);
assert.notDeepStrictEqual(result1, result3);
});
it('should invalidate cache when time passes quarantine expiry', () => {
const manager = new RelayHealthManager();
const config = {
rollingWindowSize: 5,
failureThreshold: 1, // fails immediately
quarantineCooldownMs: 1000,
maxQuarantineCooldownMs: 5000,
minActiveRelayPool: 1,
};
let now = 10000;
manager.recordOutcome('wss://r1', false, 'fail', 100, config, now);
// r1 is quarantined until 11000
const result1 = manager.rankRelays(['wss://r1'], config, now);
assert.strictEqual(result1[0].summary.quarantined, true);
assert.strictEqual(manager._sortedCache.validUntil, 11000);
// Advance time but before expiry
now = 10500;
const result2 = manager.rankRelays(['wss://r1'], config, now);
// Cache should still be valid (version same, now < validUntil)
// Quarantine remaining should be less
assert.strictEqual(result2[0].summary.quarantined, true);
assert.strictEqual(result2[0].summary.quarantineRemainingMs, 500);
// Advance time past expiry
now = 11001;
const result3 = manager.rankRelays(['wss://r1'], config, now);
// Cache should be invalidated because now >= validUntil
assert.strictEqual(result3[0].summary.quarantined, false);
assert.strictEqual(result3[0].summary.quarantineRemainingMs, 0);
// validUntil should be Infinity (no quarantined relays)
assert.strictEqual(manager._sortedCache.validUntil, Infinity);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert';
import path from 'node:path';
import { getRoster, _resetRosterCache } from '../src/roster.mjs';
describe('Roster (Unit Tests with Dependency Injection)', () => {
const CWD = process.cwd();
const USER_ROSTER_FILE = path.resolve(CWD, 'torch/roster.json');
const CWD_ROSTER_FILE = path.resolve(CWD, 'roster.json');
const mockFs = {
existsSync: mock.fn(),
readFileSync: mock.fn(),
};
const mockLoadTorchConfig = mock.fn();
const originalConsoleError = console.error;
beforeEach(() => {
_resetRosterCache();
mockFs.existsSync.mock.resetCalls();
mockFs.readFileSync.mock.resetCalls();
mockLoadTorchConfig.mock.resetCalls();
console.error = mock.fn();
// Default: config returns empty nostrLock
mockLoadTorchConfig.mock.mockImplementation(async () => ({ nostrLock: {} }));
// Default: fs.existsSync returns false (no files found)
mockFs.existsSync.mock.mockImplementation(() => false);
// Default: fs.readFileSync returns empty object
mockFs.readFileSync.mock.mockImplementation(() => '{}');
// Clear env vars
delete process.env.NOSTR_LOCK_DAILY_ROSTER;
delete process.env.NOSTR_LOCK_WEEKLY_ROSTER;
});
afterEach(() => {
mock.reset();
console.error = originalConsoleError;
});
it('returns fallback roster when no configuration or files exist', async () => {
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const roster = await getRoster('daily', deps);
assert.ok(Array.isArray(roster));
assert.ok(roster.length > 0);
assert.ok(roster.includes('audit-agent'));
});
it('loads roster from torch/roster.json (User Roster) if present', async () => {
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
mockFs.readFileSync.mock.mockImplementation((filepath) => {
if (filepath === USER_ROSTER_FILE) {
return JSON.stringify({ daily: ['user-agent-daily'], weekly: ['user-agent-weekly'] });
}
throw new Error('ENOENT');
});
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const daily = await getRoster('daily', deps);
const weekly = await getRoster('weekly', deps);
assert.deepStrictEqual(daily, ['user-agent-daily']);
assert.deepStrictEqual(weekly, ['user-agent-weekly']);
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1);
});
it('loads roster from roster.json (CWD Roster) if present and torch/roster.json is missing', async () => {
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === CWD_ROSTER_FILE);
mockFs.readFileSync.mock.mockImplementation((filepath) => {
if (filepath === CWD_ROSTER_FILE) {
return JSON.stringify({ daily: ['cwd-agent-daily'], weekly: ['cwd-agent-weekly'] });
}
throw new Error('ENOENT');
});
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const daily = await getRoster('daily', deps);
assert.deepStrictEqual(daily, ['cwd-agent-daily']);
});
it('falls back to default if roster file is malformed', async () => {
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
mockFs.readFileSync.mock.mockImplementation(() => '{ invalid json }');
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
// Should catch error and return fallback
const daily = await getRoster('daily', deps);
assert.ok(daily.includes('audit-agent'));
// Should have tried to read file
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1);
});
it('falls back to default if roster file is missing daily/weekly arrays', async () => {
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ other: [] }));
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const daily = await getRoster('daily', deps);
assert.ok(daily.includes('audit-agent'));
});
it('prioritizes environment variables over file and config', async () => {
process.env.NOSTR_LOCK_DAILY_ROSTER = 'env-agent-daily';
// Setup file to exist
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] }));
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const daily = await getRoster('daily', deps);
assert.deepStrictEqual(daily, ['env-agent-daily']);
});
it('prioritizes config over file', async () => {
mockLoadTorchConfig.mock.mockImplementation(async () => ({
nostrLock: { dailyRoster: ['config-agent-daily'] }
}));
// Setup file to exist
mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE);
mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] }));
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const daily = await getRoster('daily', deps);
assert.deepStrictEqual(daily, ['config-agent-daily']);
});
it('handles multiple items in env var roster', async () => {
process.env.NOSTR_LOCK_DAILY_ROSTER = 'agent1, agent2,agent3 ';
const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig };
const daily = await getRoster('daily', deps);
assert.deepStrictEqual(daily, ['agent1', 'agent2', 'agent3']);
});
});
@@ -71,6 +71,8 @@ async function setupFixture({
await fs.mkdir(binDir, { recursive: true });
await fs.copyFile(SOURCE_SCRIPT, path.join(scriptsDir, 'run-scheduler-cycle.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-utils.mjs'), path.join(scriptsDir, 'scheduler-utils.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-lock.mjs'), path.join(scriptsDir, 'scheduler-lock.mjs'));
await fs.writeFile(
path.join(scriptsDir, 'verify-run-artifacts.mjs'),
'#!/usr/bin/env node\nprocess.exit(0);\n',
@@ -93,6 +93,8 @@ async function setupFixture({ lockShellBody }) {
await fs.mkdir(binDir, { recursive: true });
await fs.copyFile(SOURCE_SCRIPT, path.join(scriptsDir, 'run-scheduler-cycle.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-utils.mjs'), path.join(scriptsDir, 'scheduler-utils.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-lock.mjs'), path.join(scriptsDir, 'scheduler-lock.mjs'));
await fs.writeFile(path.join(scriptsDir, 'verify-run-artifacts.mjs'), '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8');
await fs.writeFile(path.join(root, 'src', 'prompts', 'roster.json'), JSON.stringify({ daily: ['agent-a'] }, null, 2), 'utf8');
await fs.writeFile(path.join(root, 'src', 'prompts', 'daily', 'agent-a.md'), '# agent-a\n', 'utf8');
@@ -42,6 +42,8 @@ async function setupFixture({ lockShellBody = ':', schedulerPolicy = {} }) {
await fs.mkdir(binDir, { recursive: true });
await fs.copyFile(SOURCE_SCRIPT, path.join(scriptsDir, 'run-scheduler-cycle.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-utils.mjs'), path.join(scriptsDir, 'scheduler-utils.mjs'));
await fs.copyFile(path.resolve('scripts/agent/scheduler-lock.mjs'), path.join(scriptsDir, 'scheduler-lock.mjs'));
await fs.writeFile(
path.join(scriptsDir, 'verify-run-artifacts.mjs'),
'#!/usr/bin/env node\nprocess.exit(0);\n',
+3 -1
View File
@@ -150,9 +150,9 @@ describe('Scheduler cycle ordering guarantees', () => {
it('includes lock backend failure metadata and classifier checkpoints', async () => {
const { readFile } = await import('node:fs/promises');
const source = await readFile(new URL('../scripts/agent/run-scheduler-cycle.mjs', import.meta.url), 'utf8');
const lockSource = await readFile(new URL('../scripts/agent/scheduler-lock.mjs', import.meta.url), 'utf8');
const requiredSnippets = [
'function classifyLockBackendError(outputText)',
"backend_category: backendCategory",
"lock_command: lockCommand",
"lock_stderr_excerpt: stderrExcerpt || '(empty)'",
@@ -163,6 +163,8 @@ describe('Scheduler cycle ordering guarantees', () => {
for (const snippet of requiredSnippets) {
assert.ok(source.includes(snippet), `Expected scheduler snippet not found: ${snippet}`);
}
assert.ok(lockSource.includes('function classifyLockBackendError(outputText)'), 'Expected classifyLockBackendError in scheduler-lock.mjs');
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { _secureRandom } from '../src/lock-ops.mjs';
describe('secureRandom', () => {
it('returns values in [0, 1)', () => {
for (let i = 0; i < 1000; i++) {
const val = _secureRandom();
assert.ok(val >= 0, `Value ${val} should be >= 0`);
assert.ok(val < 1, `Value ${val} should be < 1`);
}
});
it('returns varied values', () => {
const values = new Set();
for (let i = 0; i < 100; i++) {
values.add(_secureRandom());
}
assert.ok(values.size > 90, 'Values should be reasonably unique');
});
});
+62 -27
View File
@@ -7,65 +7,100 @@ 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)', async () => {
const mockFs = {
promises: {
readFile: mock.fn(async () => JSON.stringify({
nostrLock: {
relays: [],
relayFallbacks: []
},
dashboard: {
relays: []
}
}))
}
};
const config = await loadTorchConfig(mockFs);
// New behavior: all return null
assert.strictEqual(config.nostrLock.relays, null);
assert.strictEqual(config.dashboard.relays, null);
assert.strictEqual(config.nostrLock.relayFallbacks, null);
});
});
+14 -14
View File
@@ -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/);
});
});
});
+36 -2
View File
@@ -1,6 +1,8 @@
import { describe, it } from 'node:test';
import { describe, it, after } from 'node:test';
import assert from 'node:assert';
import { getIsoWeekStr } from '../src/utils.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { getIsoWeekStr, ensureDir } from '../src/utils.mjs';
describe('Date Utilities', () => {
describe('getIsoWeekStr', () => {
@@ -34,3 +36,35 @@ describe('Date Utilities', () => {
});
});
});
describe('File Utilities', () => {
describe('ensureDir', () => {
const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-utils-'));
after(() => {
fs.rmSync(tempBase, { recursive: true, force: true });
});
it('creates directory if it does not exist', () => {
const targetDir = path.join(tempBase, 'new-dir');
assert.strictEqual(fs.existsSync(targetDir), false);
ensureDir(targetDir);
assert.strictEqual(fs.existsSync(targetDir), true);
});
it('does nothing if directory already exists', () => {
const targetDir = path.join(tempBase, 'existing-dir');
fs.mkdirSync(targetDir);
assert.strictEqual(fs.existsSync(targetDir), true);
ensureDir(targetDir);
assert.strictEqual(fs.existsSync(targetDir), true);
});
it('creates nested directories recursively', () => {
const targetDir = path.join(tempBase, 'nested', 'deep', 'dir');
assert.strictEqual(fs.existsSync(targetDir), false);
ensureDir(targetDir);
assert.strictEqual(fs.existsSync(targetDir), true);
});
});
});