mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 12:51:54 +00:00
Merge branch 'main' into jules/testing-relay-health-12520607155456440581
This commit is contained in:
133 files changed
+11210
-2781
No files matched your search
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user