mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-06 12:51:54 +00:00
Remove superseded Nostr implementation
This commit is contained in:
1 parent
87001555ea
commit
056fab8af4
802 files changed
+43
-104020
No files matched your search
@@ -1 +0,0 @@
|
||||
Lock backend error (relay_publish_quorum_failure) after 3 attempt(s). Prompt not executed. Recommended auto-remediation: Likely backend/relay connectivity issue during lock acquisition. Retry window: 60 minute(s) (max deferrals: 3). Retry command: npm run scheduler:daily Run health check: npm run lock:health -- --cadence daily Review incident runbook: docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md
|
||||
@@ -1 +0,0 @@
|
||||
Prompt not executed; unable to read prompt file at <PROMPT_PATH>: ENOENT: no such file or directory, open '<PROMPT_PATH>'
|
||||
@@ -1,34 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
|
||||
const DIST_DIR = path.resolve('dist');
|
||||
|
||||
test('Build artifacts verification', { skip: !fs.existsSync(DIST_DIR) && 'dist/ not found — run npm run build first' }, async (t) => {
|
||||
await t.test('dist directory exists', () => {
|
||||
assert.strictEqual(fs.existsSync(DIST_DIR), true, 'dist directory should exist');
|
||||
});
|
||||
|
||||
await t.test('Critical files exist in dist', () => {
|
||||
const criticalFiles = [
|
||||
'index.html',
|
||||
'dashboard/index.html',
|
||||
'src/docs/TORCH.md',
|
||||
'assets/logo.svg',
|
||||
'src/constants.mjs',
|
||||
'src/prompts/META_PROMPTS.md'
|
||||
];
|
||||
|
||||
criticalFiles.forEach(file => {
|
||||
const filePath = path.join(DIST_DIR, file);
|
||||
assert.strictEqual(fs.existsSync(filePath), true, `File ${file} should exist in dist`);
|
||||
});
|
||||
});
|
||||
|
||||
await t.test('TORCH.md content is not empty', () => {
|
||||
const torchMdPath = path.join(DIST_DIR, 'src/docs/TORCH.md');
|
||||
const content = fs.readFileSync(torchMdPath, 'utf8');
|
||||
assert.ok(content.length > 0, 'TORCH.md should not be empty');
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { parseArgs } from '../src/cli-parser.mjs';
|
||||
import { DEFAULT_DASHBOARD_PORT } from '../src/constants.mjs';
|
||||
|
||||
test('parseArgs should parse basic command', () => {
|
||||
const args = parseArgs(['check', '--cadence', 'daily']);
|
||||
assert.strictEqual(args.command, 'check');
|
||||
assert.strictEqual(args.cadence, 'daily');
|
||||
});
|
||||
|
||||
test('parseArgs should parse agent and cadence', () => {
|
||||
const args = parseArgs(['lock', '--agent', 'my-agent', '--cadence', 'weekly']);
|
||||
assert.strictEqual(args.command, 'lock');
|
||||
assert.strictEqual(args.agent, 'my-agent');
|
||||
assert.strictEqual(args.cadence, 'weekly');
|
||||
});
|
||||
|
||||
test('parseArgs should parse equals sign syntax', () => {
|
||||
const args = parseArgs(['lock', '--agent=my-agent', '--cadence=weekly']);
|
||||
assert.strictEqual(args.command, 'lock');
|
||||
assert.strictEqual(args.agent, 'my-agent');
|
||||
assert.strictEqual(args.cadence, 'weekly');
|
||||
});
|
||||
|
||||
test('parseArgs should parse dry-run flag', () => {
|
||||
const args = parseArgs(['lock', '--dry-run']);
|
||||
assert.strictEqual(args.dryRun, true);
|
||||
});
|
||||
|
||||
test('parseArgs should parse port with default', () => {
|
||||
const args = parseArgs(['dashboard']);
|
||||
assert.strictEqual(args.port, DEFAULT_DASHBOARD_PORT);
|
||||
});
|
||||
|
||||
test('parseArgs should parse custom port', () => {
|
||||
const args = parseArgs(['dashboard', '--port', '8080']);
|
||||
assert.strictEqual(args.port, 8080);
|
||||
});
|
||||
|
||||
test('parseArgs should parse ignore-logs flag', () => {
|
||||
const args = parseArgs(['check', '--ignore-logs']);
|
||||
assert.strictEqual(args.ignoreLogs, true);
|
||||
});
|
||||
|
||||
test('parseArgs should parse memory flags', () => {
|
||||
const args = parseArgs([
|
||||
'list-memories',
|
||||
'--id', '123',
|
||||
'--type', 'retrieve',
|
||||
'--tags', 'a,b',
|
||||
'--pinned', 'true',
|
||||
'--limit', '10',
|
||||
'--offset', '5'
|
||||
]);
|
||||
assert.strictEqual(args.id, '123');
|
||||
assert.strictEqual(args.type, 'retrieve');
|
||||
assert.deepStrictEqual(args.tags, ['a', 'b']);
|
||||
assert.strictEqual(args.pinned, true);
|
||||
assert.strictEqual(args.limit, 10);
|
||||
assert.strictEqual(args.offset, 5);
|
||||
});
|
||||
|
||||
test('parseArgs should parse memory flags with equals', () => {
|
||||
const args = parseArgs([
|
||||
'list-memories',
|
||||
'--id=123',
|
||||
'--type=retrieve',
|
||||
'--tags=a,b',
|
||||
'--pinned=false',
|
||||
'--limit=10',
|
||||
'--offset=5'
|
||||
]);
|
||||
assert.strictEqual(args.id, '123');
|
||||
assert.strictEqual(args.type, 'retrieve');
|
||||
assert.deepStrictEqual(args.tags, ['a', 'b']);
|
||||
assert.strictEqual(args.pinned, false);
|
||||
assert.strictEqual(args.limit, 10);
|
||||
assert.strictEqual(args.offset, 5);
|
||||
});
|
||||
|
||||
test('parseArgs should parse memory subcommands and event contract flags', () => {
|
||||
const args = parseArgs([
|
||||
'memory',
|
||||
'add',
|
||||
'--agent', 'codex',
|
||||
'--topic', 'memory',
|
||||
'--message', 'hello',
|
||||
'--run-id', 'run_1',
|
||||
'--events-dir', 'memory/events',
|
||||
'--schema-path', 'memory/schema/memory-event.schema.json',
|
||||
'--created-at', '2026-03-01T15:20:11Z',
|
||||
'--source', 'scheduler',
|
||||
'--project', 'TORCH',
|
||||
]);
|
||||
|
||||
assert.strictEqual(args.command, 'memory');
|
||||
assert.strictEqual(args.subcommand, 'add');
|
||||
assert.strictEqual(args.agent, 'codex');
|
||||
assert.strictEqual(args.topic, 'memory');
|
||||
assert.strictEqual(args.message, 'hello');
|
||||
assert.strictEqual(args.runId, 'run_1');
|
||||
assert.strictEqual(args.eventsDir, 'memory/events');
|
||||
assert.strictEqual(args.schemaPath, 'memory/schema/memory-event.schema.json');
|
||||
assert.strictEqual(args.createdAt, '2026-03-01T15:20:11Z');
|
||||
assert.strictEqual(args.source, 'scheduler');
|
||||
assert.strictEqual(args.project, 'TORCH');
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
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 { cmdInit } from '../src/ops.mjs';
|
||||
import { runDoctorChecks, cmdDoctor } from '../src/cmd-doctor.mjs';
|
||||
|
||||
const MOCK_CONFIG = {
|
||||
installDir: 'torch',
|
||||
namespace: 'doctor-test-namespace',
|
||||
hashtag: 'doctor-test-hashtag',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
};
|
||||
|
||||
test('runDoctorChecks reports failure when TORCH is not installed', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-doctor-missing-'));
|
||||
|
||||
const report = runDoctorChecks({ cwd: root, nodeVersion: '22.10.0' });
|
||||
assert.equal(report.ok, false);
|
||||
assert.ok(report.checks.some((check) => check.id === 'install-detection' && check.status === 'fail'));
|
||||
});
|
||||
|
||||
test('runDoctorChecks passes core checks after init', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-doctor-healthy-'));
|
||||
await cmdInit(false, root, MOCK_CONFIG);
|
||||
|
||||
// Simulate post-init dependency install for health check.
|
||||
await fs.mkdir(path.join(root, 'torch', 'node_modules'), { recursive: true });
|
||||
|
||||
const report = runDoctorChecks({ cwd: root, nodeVersion: '22.10.0' });
|
||||
assert.equal(report.ok, true);
|
||||
assert.ok(report.checks.some((check) => check.id === 'memory-hook' && check.status === 'pass'));
|
||||
assert.ok(report.checks.some((check) => check.id === 'install-files' && check.status === 'pass'));
|
||||
});
|
||||
|
||||
test('runDoctorChecks fails when memory hook is missing from existing AGENTS.md', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-doctor-hook-missing-'));
|
||||
await cmdInit(false, root, MOCK_CONFIG);
|
||||
|
||||
await fs.writeFile(path.join(root, 'AGENTS.md'), '# AGENTS\nNo memory section.\n', 'utf8');
|
||||
|
||||
const report = runDoctorChecks({ cwd: root, nodeVersion: '22.10.0' });
|
||||
assert.equal(report.ok, false);
|
||||
const memoryCheck = report.checks.find((check) => check.id === 'memory-hook');
|
||||
assert.ok(memoryCheck);
|
||||
assert.equal(memoryCheck.status, 'fail');
|
||||
});
|
||||
|
||||
test('cmdDoctor emits JSON report when requested', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-doctor-json-'));
|
||||
await cmdInit(false, root, MOCK_CONFIG);
|
||||
|
||||
const logs = [];
|
||||
const report = cmdDoctor({
|
||||
cwd: root,
|
||||
json: true,
|
||||
nodeVersion: '22.10.0',
|
||||
log: (line) => logs.push(String(line)),
|
||||
});
|
||||
|
||||
assert.ok(report.summary.passed >= 1);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.doesNotThrow(() => JSON.parse(logs[0]));
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdList } from '../src/cmd-list.mjs';
|
||||
|
||||
describe('cmdList', () => {
|
||||
it('should list active locks and identify unknown agents', async () => {
|
||||
const logs = [];
|
||||
|
||||
const mockLocks = [
|
||||
{
|
||||
agent: 'agent-1',
|
||||
createdAt: 1000,
|
||||
expiresAt: 4600,
|
||||
eventId: 'event-1',
|
||||
platform: 'linux',
|
||||
},
|
||||
{
|
||||
agent: 'unknown-agent',
|
||||
createdAt: 2000,
|
||||
eventId: 'event-2',
|
||||
platform: 'mac',
|
||||
},
|
||||
];
|
||||
|
||||
const deps = {
|
||||
getRelays: async () => ['wss://relay.example.com'],
|
||||
getNamespace: async () => 'test-namespace',
|
||||
queryLocks: async () => mockLocks,
|
||||
getRoster: async () => ['agent-1', 'agent-2'],
|
||||
todayDateStr: () => '2023-10-27',
|
||||
nowUnix: () => 3000, // agent-1 age: 2000s (~33m), unknown-agent age: 1000s (~17m)
|
||||
log: (...args) => logs.push(args.join(' ')),
|
||||
error: (...args) => logs.push(args.join(' ')),
|
||||
};
|
||||
|
||||
await cmdList('daily', deps);
|
||||
|
||||
const output = logs.join('\n');
|
||||
// Verify header
|
||||
assert.match(output, /Active test-namespace daily locks/);
|
||||
|
||||
// Verify agents listed
|
||||
assert.match(output, /agent-1/);
|
||||
assert.match(output, /unknown-agent/);
|
||||
|
||||
// Verify unknown warning
|
||||
assert.match(output, /Warning: lock events found with non-roster agent names: unknown-agent/);
|
||||
|
||||
// Verify summary stats
|
||||
// Locked: 2 (agent-1 and unknown-agent both count as locked agents) / Roster size 2
|
||||
assert.match(output, /Locked: 2\/2/);
|
||||
|
||||
// Verify available
|
||||
// agent-2 is in roster and not in lockedAgents
|
||||
assert.match(output, /Available: agent-2/);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { cmdMemoryAdd, cmdMemoryBuild, cmdMemoryVerify } from '../src/cmd-memory.mjs';
|
||||
import { ExitError } from '../src/errors.mjs';
|
||||
|
||||
const SCHEMA_PATH = 'memory/schema/memory-event.schema.json';
|
||||
|
||||
async function withTempProject(fn) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-memory-cmd-'));
|
||||
const cwd = process.cwd();
|
||||
process.chdir(root);
|
||||
try {
|
||||
await fs.mkdir('memory/events', { recursive: true });
|
||||
await fs.mkdir('memory/schema', { recursive: true });
|
||||
const schemaSource = path.resolve(cwd, SCHEMA_PATH);
|
||||
await fs.copyFile(schemaSource, path.join(root, SCHEMA_PATH));
|
||||
await fn(root);
|
||||
} finally {
|
||||
process.chdir(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEvent(root, event) {
|
||||
const compact = event.created_at.replace(/[-:]/g, '').replace('.000', '');
|
||||
const year = event.created_at.slice(0, 4);
|
||||
const month = event.created_at.slice(5, 7);
|
||||
const day = event.created_at.slice(8, 10);
|
||||
const dir = path.join(root, 'memory', 'events', year, month, day);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const filePath = path.join(dir, `${compact}_${event.id}.json`);
|
||||
await fs.writeFile(filePath, `${JSON.stringify(event, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
test('memory add writes one append-only event file with schema-valid shape', async () => {
|
||||
await withTempProject(async () => {
|
||||
const createdAt = '2026-03-01T15:20:11Z';
|
||||
await cmdMemoryAdd({
|
||||
subcommand: 'add',
|
||||
agent: 'codex',
|
||||
topic: 'memory',
|
||||
message: 'Append-only event created.',
|
||||
createdAt,
|
||||
runId: 'run_abc123',
|
||||
tags: ['architecture', 'memory'],
|
||||
source: 'test',
|
||||
});
|
||||
|
||||
const eventDir = path.resolve('memory/events/2026/03/01');
|
||||
const files = await fs.readdir(eventDir);
|
||||
assert.strictEqual(files.length, 1);
|
||||
const json = JSON.parse(await fs.readFile(path.join(eventDir, files[0]), 'utf8'));
|
||||
assert.strictEqual(json.project, 'TORCH');
|
||||
assert.strictEqual(json.created_at, createdAt);
|
||||
assert.strictEqual(json.agent, 'codex');
|
||||
assert.ok(json.id.includes('-7'));
|
||||
});
|
||||
});
|
||||
|
||||
test('memory build renders deterministic markdown sorted by created_at then id', async () => {
|
||||
await withTempProject(async (root) => {
|
||||
await writeEvent(root, {
|
||||
id: '0195505d-0000-7000-8000-000000000002',
|
||||
created_at: '2026-03-01T15:20:11Z',
|
||||
project: 'TORCH',
|
||||
agent: 'codex',
|
||||
topic: 'memory',
|
||||
content: 'second',
|
||||
});
|
||||
await writeEvent(root, {
|
||||
id: '0195505d-0000-7000-8000-000000000001',
|
||||
created_at: '2026-03-01T15:20:11Z',
|
||||
project: 'TORCH',
|
||||
agent: 'codex',
|
||||
topic: 'memory',
|
||||
content: 'first',
|
||||
});
|
||||
|
||||
await cmdMemoryBuild({ subcommand: 'build' });
|
||||
const rendered = await fs.readFile('memory_update.md', 'utf8');
|
||||
const firstPos = rendered.indexOf('000000000001');
|
||||
const secondPos = rendered.indexOf('000000000002');
|
||||
assert.ok(firstPos > 0);
|
||||
assert.ok(secondPos > firstPos);
|
||||
assert.ok(rendered.startsWith('<!-- AUTO-GENERATED: DO NOT EDIT. Source: memory/events -->'));
|
||||
});
|
||||
});
|
||||
|
||||
test('memory verify fails when generated output is stale', async () => {
|
||||
await withTempProject(async (root) => {
|
||||
await writeEvent(root, {
|
||||
id: '0195505d-0000-7000-8000-000000000001',
|
||||
created_at: '2026-03-01T15:20:11Z',
|
||||
project: 'TORCH',
|
||||
agent: 'codex',
|
||||
topic: 'memory',
|
||||
content: 'expected output',
|
||||
});
|
||||
await fs.writeFile('memory_update.md', 'manually edited\n', 'utf8');
|
||||
|
||||
await assert.rejects(
|
||||
async () => cmdMemoryVerify({ subcommand: 'verify' }),
|
||||
(error) => error instanceof ExitError && error.code === 4 && /out of date|missing generated-file header/i.test(error.message),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('memory verify fails on duplicate event ids', async () => {
|
||||
await withTempProject(async (root) => {
|
||||
const sharedId = '0195505d-0000-7000-8000-000000000001';
|
||||
await writeEvent(root, {
|
||||
id: sharedId,
|
||||
created_at: '2026-03-01T15:20:11Z',
|
||||
project: 'TORCH',
|
||||
agent: 'codex',
|
||||
topic: 'memory',
|
||||
content: 'first',
|
||||
});
|
||||
await writeEvent(root, {
|
||||
id: sharedId,
|
||||
created_at: '2026-03-01T15:20:12Z',
|
||||
project: 'TORCH',
|
||||
agent: 'codex',
|
||||
topic: 'memory',
|
||||
content: 'second',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => cmdMemoryVerify({ subcommand: 'verify' }),
|
||||
(error) => error instanceof ExitError && error.code === 4 && /duplicate id/i.test(error.message),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cmdInit } from '../src/ops.mjs';
|
||||
import { cmdRemove } from '../src/cmd-remove.mjs';
|
||||
|
||||
const MOCK_CONFIG = {
|
||||
installDir: 'torch',
|
||||
namespace: 'test-namespace',
|
||||
relays: ['wss://relay.damus.io']
|
||||
};
|
||||
|
||||
const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-ops-remove-'));
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tempBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('cmdRemove reports nothing when no TORCH installation exists', async () => {
|
||||
const projectRoot = path.join(tempBase, 'empty-project');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
// Create a basic package.json so it looks like a real project
|
||||
fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ name: 'my-app' }));
|
||||
|
||||
// Should complete without error — just reports nothing found
|
||||
await cmdRemove(true, projectRoot);
|
||||
|
||||
// Project should be untouched
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, 'package.json')), 'package.json still exists');
|
||||
});
|
||||
|
||||
test('cmdRemove with --force removes torch/ directory', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project-force');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
// Verify init created the expected artifacts
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ exists before remove');
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'torch-config.json exists before remove');
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, '.torch')), '.torch/ exists before remove');
|
||||
|
||||
await cmdRemove(true, projectRoot);
|
||||
|
||||
// All TORCH artifacts should be gone
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ removed');
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'torch-config.json removed');
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, '.torch')), '.torch/ removed');
|
||||
});
|
||||
|
||||
test('cmdRemove removes runtime artifacts (task-logs, .scheduler-memory)', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project-runtime');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
// Simulate runtime artifacts that the scheduler would create
|
||||
fs.mkdirSync(path.join(projectRoot, 'task-logs', 'daily'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'task-logs', 'daily', 'run.md'), '# log');
|
||||
fs.mkdirSync(path.join(projectRoot, '.scheduler-memory'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, '.scheduler-memory', 'memory-store.json'), '{}');
|
||||
|
||||
await cmdRemove(true, projectRoot);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'task-logs')), 'task-logs/ removed');
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, '.scheduler-memory')), '.scheduler-memory/ removed');
|
||||
});
|
||||
|
||||
test('cmdRemove removes src/proposals/ and cleans empty src/', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project-proposals');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
// src/proposals/ is created by cmdInit
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, 'src', 'proposals')), 'src/proposals/ exists before remove');
|
||||
|
||||
await cmdRemove(true, projectRoot);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'src', 'proposals')), 'src/proposals/ removed');
|
||||
// src/ should also be removed since it's now empty
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'src')), 'empty src/ removed');
|
||||
});
|
||||
|
||||
test('cmdRemove cleans torch:* scripts from host package.json', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project-scripts');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
// Create a host package.json with existing user scripts
|
||||
fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({
|
||||
name: 'my-app',
|
||||
scripts: {
|
||||
test: 'jest',
|
||||
build: 'webpack'
|
||||
}
|
||||
}, null, 2));
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
// Verify init injected torch scripts
|
||||
const pkgBefore = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
||||
assert.ok('torch:dashboard' in pkgBefore.scripts, 'torch:dashboard injected');
|
||||
assert.ok('torch:check' in pkgBefore.scripts, 'torch:check injected');
|
||||
|
||||
await cmdRemove(true, projectRoot);
|
||||
|
||||
// User scripts should remain, torch scripts should be gone
|
||||
const pkgAfter = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
||||
assert.strictEqual(pkgAfter.scripts.test, 'jest', 'user test script preserved');
|
||||
assert.strictEqual(pkgAfter.scripts.build, 'webpack', 'user build script preserved');
|
||||
assert.ok(!('torch:dashboard' in pkgAfter.scripts), 'torch:dashboard removed');
|
||||
assert.ok(!('torch:check' in pkgAfter.scripts), 'torch:check removed');
|
||||
assert.ok(!('torch:lock' in pkgAfter.scripts), 'torch:lock removed');
|
||||
assert.ok(!('torch:health' in pkgAfter.scripts), 'torch:health removed');
|
||||
assert.ok(!('torch:memory:list' in pkgAfter.scripts), 'torch:memory:list removed');
|
||||
assert.ok(!('torch:memory:inspect' in pkgAfter.scripts), 'torch:memory:inspect removed');
|
||||
});
|
||||
|
||||
test('cmdRemove cancels when user declines confirmation', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project-cancel');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
// Decline the confirmation
|
||||
await cmdRemove(false, projectRoot, { confirm: false });
|
||||
|
||||
// Everything should still exist
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ still exists after cancel');
|
||||
assert.ok(fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'config still exists after cancel');
|
||||
});
|
||||
|
||||
test('cmdRemove confirmed via mockAnswers removes artifacts', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project-confirm');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
// Confirm the removal
|
||||
await cmdRemove(false, projectRoot, { confirm: true });
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ removed after confirm');
|
||||
assert.ok(!fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'config removed after confirm');
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import http from 'node:http';
|
||||
import { cmdDashboard } from '../src/dashboard.mjs';
|
||||
import { _resetTorchConfigCache } from '../src/torch-config.mjs';
|
||||
|
||||
test('Dashboard Authentication', async (t) => {
|
||||
let testPort = 0;
|
||||
let server;
|
||||
|
||||
t.beforeEach(async () => {
|
||||
// We don't start server here because some tests need env vars set BEFORE starting
|
||||
});
|
||||
|
||||
t.afterEach((done) => {
|
||||
if (server) server.close(done);
|
||||
else done();
|
||||
server = null;
|
||||
});
|
||||
|
||||
await t.test('returns 401 when auth is required but missing', async () => {
|
||||
process.env.TORCH_DASHBOARD_AUTH = 'admin:password';
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const res = await new Promise((resolve) => {
|
||||
http.get(`http://127.0.0.1:${testPort}/dashboard/`, resolve);
|
||||
});
|
||||
|
||||
assert.strictEqual(res.statusCode, 401);
|
||||
assert.strictEqual(res.headers['www-authenticate'], 'Basic realm="TORCH Dashboard"');
|
||||
} finally {
|
||||
delete process.env.TORCH_DASHBOARD_AUTH;
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('returns 401 when malformed auth header is provided', async () => {
|
||||
process.env.TORCH_DASHBOARD_AUTH = 'admin:password';
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const res = await new Promise((resolve) => {
|
||||
http.get({
|
||||
host: '127.0.0.1',
|
||||
port: testPort,
|
||||
path: '/dashboard/',
|
||||
headers: { 'Authorization': 'Basic' }
|
||||
}, resolve);
|
||||
});
|
||||
|
||||
assert.strictEqual(res.statusCode, 401);
|
||||
} finally {
|
||||
delete process.env.TORCH_DASHBOARD_AUTH;
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('returns 200 when valid credentials are provided', async () => {
|
||||
process.env.TORCH_DASHBOARD_AUTH = 'admin:password';
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const auth = Buffer.from('admin:password').toString('base64');
|
||||
const res = await new Promise((resolve) => {
|
||||
http.get({
|
||||
host: '127.0.0.1',
|
||||
port: testPort,
|
||||
path: '/dashboard/',
|
||||
headers: { 'Authorization': `Basic ${auth}` }
|
||||
}, resolve);
|
||||
});
|
||||
|
||||
// We expect 200 (if dashboard/index.html exists) or 404 (if not), but not 401
|
||||
assert.notStrictEqual(res.statusCode, 401);
|
||||
} finally {
|
||||
delete process.env.TORCH_DASHBOARD_AUTH;
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('returns 401 when password has different byte length than stored credential', async () => {
|
||||
// Regression test for timingSafeCompare: previously the comparison result
|
||||
// was discarded and `false` was returned unconditionally for unequal-length
|
||||
// inputs, making the timing-safe branch dead code. Verify that a prefix of
|
||||
// the correct password (shorter) and an extended version (longer) are both
|
||||
// rejected.
|
||||
process.env.TORCH_DASHBOARD_AUTH = 'admin:secret';
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const makeRequest = (credentials) => {
|
||||
const auth = Buffer.from(credentials).toString('base64');
|
||||
return new Promise((resolve) => {
|
||||
http.get({
|
||||
host: '127.0.0.1',
|
||||
port: testPort,
|
||||
path: '/dashboard/',
|
||||
headers: { 'Authorization': `Basic ${auth}` }
|
||||
}, resolve);
|
||||
});
|
||||
};
|
||||
|
||||
// Shorter than correct password — different byte length
|
||||
const resShorter = await makeRequest('admin:secre');
|
||||
assert.strictEqual(resShorter.statusCode, 401, 'shorter password must be rejected');
|
||||
|
||||
// Longer than correct password — different byte length
|
||||
const resLonger = await makeRequest('admin:secret_extra');
|
||||
assert.strictEqual(resLonger.statusCode, 401, 'longer password must be rejected');
|
||||
|
||||
// Correct credentials still work
|
||||
const resCorrect = await makeRequest('admin:secret');
|
||||
assert.notStrictEqual(resCorrect.statusCode, 401, 'correct credentials must not be rejected');
|
||||
} finally {
|
||||
delete process.env.TORCH_DASHBOARD_AUTH;
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('returns 200 when auth is disabled', async () => {
|
||||
delete process.env.TORCH_DASHBOARD_AUTH;
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const res = await new Promise((resolve) => {
|
||||
http.get(`http://127.0.0.1:${testPort}/dashboard/`, resolve);
|
||||
});
|
||||
|
||||
assert.notStrictEqual(res.statusCode, 401);
|
||||
} finally {
|
||||
_resetTorchConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
|
||||
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;
|
||||
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');
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cmdDashboard } from '../src/dashboard.mjs';
|
||||
import { _resetTorchConfigCache } from '../src/torch-config.mjs';
|
||||
|
||||
const CONFIG_FILE = 'torch-config-test.json';
|
||||
|
||||
// Helper to fetch JSON
|
||||
function fetchJson(url, auth) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
headers: {}
|
||||
};
|
||||
if (auth) {
|
||||
options.headers['Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`;
|
||||
}
|
||||
http.get(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('Dashboard Configuration Security', async (t) => {
|
||||
process.env.TORCH_CONFIG_PATH = CONFIG_FILE;
|
||||
const configPath = path.resolve(process.cwd(), CONFIG_FILE);
|
||||
let server;
|
||||
|
||||
// Ensure clean state
|
||||
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
||||
_resetTorchConfigCache();
|
||||
|
||||
t.afterEach((done) => {
|
||||
if (server) {
|
||||
server.close(done);
|
||||
server = null;
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
||||
});
|
||||
|
||||
t.after(() => {
|
||||
delete process.env.TORCH_CONFIG_PATH;
|
||||
});
|
||||
|
||||
// Scenario 1: Unauthenticated access (no auth configured), sensitive fields in config
|
||||
await t.test('sanitizes config when auth is disabled', async () => {
|
||||
const sensitiveConfig = {
|
||||
dashboard: {
|
||||
relays: ["wss://public.relay.com"]
|
||||
},
|
||||
super_secret_key: "DO_NOT_LEAK",
|
||||
nostrLock: {
|
||||
relays: ["wss://lock.relay.com"]
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(sensitiveConfig, null, 2));
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
const testPort = server.address().port;
|
||||
|
||||
try {
|
||||
const { statusCode, body } = await fetchJson(`http://127.0.0.1:${testPort}/torch-config.json`);
|
||||
|
||||
assert.strictEqual(statusCode, 200);
|
||||
const json = JSON.parse(body);
|
||||
|
||||
// Check for leaks
|
||||
assert.strictEqual(json.super_secret_key, undefined, 'Unknown fields should be stripped');
|
||||
|
||||
// Check for valid fields
|
||||
assert.deepStrictEqual(json.dashboard.relays, sensitiveConfig.dashboard.relays);
|
||||
assert.deepStrictEqual(json.nostrLock.relays, sensitiveConfig.nostrLock.relays);
|
||||
|
||||
// Check that configPath is not leaked
|
||||
assert.strictEqual(json.configPath, undefined, 'configPath should not be exposed');
|
||||
|
||||
} finally {
|
||||
// server closed by afterEach
|
||||
}
|
||||
});
|
||||
|
||||
// Scenario 2: Authenticated access, auth credentials in config
|
||||
await t.test('sanitizes auth credentials even when authenticated', async () => {
|
||||
const authConfig = {
|
||||
dashboard: {
|
||||
auth: "admin:password123",
|
||||
relays: ["wss://secure.relay.com"]
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(authConfig, null, 2));
|
||||
_resetTorchConfigCache();
|
||||
|
||||
server = await cmdDashboard(0);
|
||||
const testPort = server.address().port;
|
||||
|
||||
try {
|
||||
// 1. Verify 401 without auth
|
||||
const res401 = await fetchJson(`http://127.0.0.1:${testPort}/torch-config.json`);
|
||||
assert.strictEqual(res401.statusCode, 401);
|
||||
|
||||
// 2. Verify 200 with auth
|
||||
const { statusCode, body } = await fetchJson(`http://127.0.0.1:${testPort}/torch-config.json`, "admin:password123");
|
||||
assert.strictEqual(statusCode, 200);
|
||||
|
||||
const json = JSON.parse(body);
|
||||
|
||||
// Check for leaks
|
||||
assert.strictEqual(json.dashboard.auth, undefined, 'Auth credentials should be stripped');
|
||||
|
||||
// Check for valid fields
|
||||
assert.deepStrictEqual(json.dashboard.relays, authConfig.dashboard.relays);
|
||||
|
||||
} finally {
|
||||
// server closed by afterEach
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { cmdDashboard } from '../src/dashboard.mjs';
|
||||
|
||||
test('Dashboard File Access Security', async (t) => {
|
||||
let testPort = 0;
|
||||
let server;
|
||||
|
||||
t.beforeEach(async () => {
|
||||
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) => {
|
||||
http.get({
|
||||
host: '127.0.0.1',
|
||||
port: testPort,
|
||||
path: path
|
||||
}, (res) => {
|
||||
// Consume data to avoid memory leaks
|
||||
res.resume();
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await t.test('allows access to dashboard assets', async () => {
|
||||
const res = await get('/dashboard/index.html');
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
});
|
||||
|
||||
await t.test('allows access to global assets', async () => {
|
||||
// assuming favicon.svg exists
|
||||
const res = await get('/assets/favicon.svg');
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
});
|
||||
|
||||
await t.test('allows access to landing page', async () => {
|
||||
const res = await get('/landing/index.html');
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
});
|
||||
|
||||
await t.test('blocks access to package.json', async () => {
|
||||
const res = await get('/package.json');
|
||||
assert.strictEqual(res.statusCode, 403);
|
||||
});
|
||||
|
||||
await t.test('blocks access to src/dashboard.mjs', async () => {
|
||||
const res = await get('/src/dashboard.mjs');
|
||||
assert.strictEqual(res.statusCode, 403);
|
||||
});
|
||||
|
||||
await t.test('blocks access to random root files', async () => {
|
||||
const res = await get('/README.md');
|
||||
assert.strictEqual(res.statusCode, 403);
|
||||
});
|
||||
|
||||
await t.test('allows access to torch-config.json via special handler', async () => {
|
||||
const res = await get('/torch-config.json');
|
||||
// It exists in root, so it should return 200
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
});
|
||||
|
||||
await t.test('blocks directory traversal attempts', async () => {
|
||||
const res = await get('/dashboard/../../package.json');
|
||||
// path.normalize collapses this to /package.json, which is then blocked
|
||||
assert.strictEqual(res.statusCode, 403);
|
||||
});
|
||||
|
||||
await t.test('responses include security headers', async () => {
|
||||
const res = await get('/dashboard/index.html');
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
|
||||
const headers = res.headers;
|
||||
|
||||
// Content-Security-Policy
|
||||
assert.ok(headers['content-security-policy'], 'CSP header missing');
|
||||
const csp = headers['content-security-policy'];
|
||||
assert.match(csp, /default-src 'self'/, 'CSP: missing default-src self');
|
||||
assert.match(csp, /script-src 'self' https:\/\/cdn\.jsdelivr\.net/, 'CSP: missing script-src');
|
||||
assert.match(csp, /object-src 'none'/, 'CSP: missing object-src none');
|
||||
|
||||
// X-Content-Type-Options
|
||||
assert.strictEqual(headers['x-content-type-options'], 'nosniff', 'X-Content-Type-Options mismatch');
|
||||
|
||||
// X-Frame-Options
|
||||
assert.strictEqual(headers['x-frame-options'], 'DENY', 'X-Frame-Options mismatch');
|
||||
|
||||
// Referrer-Policy
|
||||
assert.strictEqual(headers['referrer-policy'], 'strict-origin-when-cross-origin', 'Referrer-Policy mismatch');
|
||||
});
|
||||
|
||||
await t.test('error responses include security headers', async () => {
|
||||
const res = await get('/dashboard/nonexistent.html');
|
||||
assert.strictEqual(res.statusCode, 404);
|
||||
|
||||
const headers = res.headers;
|
||||
assert.strictEqual(headers['x-content-type-options'], 'nosniff');
|
||||
assert.strictEqual(headers['x-frame-options'], 'DENY');
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { ExitError } from '../src/errors.mjs';
|
||||
|
||||
describe('ExitError', () => {
|
||||
it('should capture the code and message correctly', () => {
|
||||
const code = 1;
|
||||
const message = 'Test error message';
|
||||
const error = new ExitError(code, message);
|
||||
|
||||
assert.strictEqual(error.code, code);
|
||||
assert.strictEqual(error.message, message);
|
||||
});
|
||||
|
||||
it('should be an instance of Error and ExitError', () => {
|
||||
const error = new ExitError(1, 'message');
|
||||
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(error instanceof ExitError);
|
||||
});
|
||||
|
||||
it('should have a stack trace', () => {
|
||||
const error = new ExitError(1, 'message');
|
||||
assert.ok(error.stack);
|
||||
});
|
||||
|
||||
it('should have the correct name', () => {
|
||||
const error = new ExitError(1, 'message');
|
||||
assert.strictEqual(error.name, 'ExitError');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
test('fail fixture stays red', () => {
|
||||
assert.equal(1, 2);
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
test('pass fixture stays green', () => {
|
||||
assert.equal(2 + 2, 4);
|
||||
});
|
||||
Vendored
-48
@@ -1,48 +0,0 @@
|
||||
export const FIXED_NOW = 1_700_000_000_000;
|
||||
|
||||
export const SYNTHETIC_EVENTS = [
|
||||
{
|
||||
agent_id: 'agent-alpha',
|
||||
content: 'Incident INC-101 resolved after restarting api service',
|
||||
timestamp: FIXED_NOW - 5_000,
|
||||
tags: ['incident', 'ops'],
|
||||
metadata: { session_id: 'session-1' },
|
||||
},
|
||||
{
|
||||
agent_id: 'agent-alpha',
|
||||
content: 'Customer email [email protected] confirmed recovery',
|
||||
timestamp: FIXED_NOW - 3_000,
|
||||
tags: ['incident', 'customer'],
|
||||
metadata: { session_id: 'session-1' },
|
||||
},
|
||||
{
|
||||
agent_id: 'agent-alpha',
|
||||
content: 'Follow-up task created for postmortem',
|
||||
timestamp: FIXED_NOW - 2_000,
|
||||
tags: ['ops', 'task'],
|
||||
metadata: { session_id: 'session-1' },
|
||||
},
|
||||
];
|
||||
|
||||
export const EXPECTED_SUMMARY = 'INC-101 resolved, customer contact [redacted:email], postmortem task queued';
|
||||
|
||||
export const EXPECTED_TAGS = ['incident', 'ops', 'customer', 'task'];
|
||||
|
||||
export const BASE_MEMORY_RECORD = {
|
||||
schema_version: 1,
|
||||
id: 'memory-1',
|
||||
agent_id: 'agent-alpha',
|
||||
session_id: 'session-1',
|
||||
type: 'event',
|
||||
content: 'Incident INC-101 resolved after restarting api service',
|
||||
summary: 'Incident resolved',
|
||||
tags: ['incident', 'ops'],
|
||||
importance: 0.7,
|
||||
embedding_id: null,
|
||||
created_at: FIXED_NOW - 5_000,
|
||||
last_seen: FIXED_NOW - 2_000,
|
||||
source: 'ingest',
|
||||
ttl_days: 30,
|
||||
merged_into: null,
|
||||
pinned: false,
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createProposal, validateProposal } from '../src/services/governance/index.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
test('createProposal should throw error for disallowed target', async () => {
|
||||
const agent = 'test-agent';
|
||||
const target = 'secrets/passwords.txt';
|
||||
const newContent = 'new content';
|
||||
|
||||
await assert.rejects(
|
||||
createProposal({ agent, target, newContent, reason: 'test' }),
|
||||
{
|
||||
message: /Target secrets\/passwords.txt is not in an allowed directory/
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('validateProposal should return invalid for disallowed target', async () => {
|
||||
const id = 'test-disallowed-proposal';
|
||||
const dir = path.resolve(process.cwd(), 'src/proposals', id);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const meta = {
|
||||
id,
|
||||
author: 'test-agent',
|
||||
target: 'secrets/passwords.txt',
|
||||
reason: 'test',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
await fs.writeFile(path.join(dir, 'new.md'), 'Shared contract (required):\nRequired startup + artifacts + memory + issue capture');
|
||||
|
||||
try {
|
||||
const result = await validateProposal(id);
|
||||
assert.strictEqual(result.valid, false);
|
||||
assert.strictEqual(result.reason, 'Target not in allowed directories.');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('validateProposal should return valid for allowed target', async () => {
|
||||
const id = 'test-allowed-proposal';
|
||||
const dir = path.resolve(process.cwd(), 'src/proposals', id);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const meta = {
|
||||
id,
|
||||
author: 'test-agent',
|
||||
target: 'src/prompts/daily/test.md',
|
||||
reason: 'test',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
await fs.writeFile(path.join(dir, 'new.md'), 'Shared contract (required):\nRequired startup + artifacts + memory + issue capture');
|
||||
|
||||
try {
|
||||
const result = await validateProposal(id);
|
||||
assert.strictEqual(result.valid, true);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,374 +0,0 @@
|
||||
/**
|
||||
* Governance: Prompt Versioning and Rollback Tests
|
||||
*
|
||||
* Scenarios tested:
|
||||
* SCN-proposal-create : createProposal writes correct on-disk structure
|
||||
* SCN-proposal-list : listProposals returns all proposals; status filter works
|
||||
* SCN-proposal-reject : rejectProposal marks status and records reason
|
||||
* SCN-archive-naming : applyProposal archives with <base>_<timestamp>_<hash>.md + sidecar
|
||||
* SCN-version-list : listPromptVersions returns versions newest-first with metadata
|
||||
* SCN-rollback-latest : rollbackPrompt --strategy latest restores most-recent archive
|
||||
* SCN-rollback-hash : rollbackPrompt --strategy <fragment> restores the matching archive
|
||||
* SCN-rollback-no-archive: rollbackPrompt falls through to git when no archive exists
|
||||
*/
|
||||
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CWD = process.cwd();
|
||||
const PROPOSALS_DIR = path.join(CWD, 'src/proposals');
|
||||
const HISTORY_DIR = path.join(CWD, '.torch/prompt-history');
|
||||
|
||||
// A real daily prompt that is guaranteed to exist (passes allowlist)
|
||||
const TEST_TARGET = 'src/prompts/daily/audit-agent.md';
|
||||
const ABS_TARGET = path.join(CWD, TEST_TARGET);
|
||||
|
||||
let originalPromptContent = null;
|
||||
const createdProposalIds = [];
|
||||
|
||||
// Read governance functions fresh each time (they're already cached by Node's ESM cache,
|
||||
// but that's fine — we need stable CWD-based paths for integration tests).
|
||||
const {
|
||||
createProposal,
|
||||
listProposals,
|
||||
getProposal,
|
||||
rejectProposal,
|
||||
applyProposal,
|
||||
listPromptVersions,
|
||||
rollbackPrompt,
|
||||
} = await import('../src/services/governance/index.js');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
before(async () => {
|
||||
// Save original prompt content so we can restore it after applyProposal tests
|
||||
originalPromptContent = await fsp.readFile(ABS_TARGET, 'utf8');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Restore original prompt content if a test changed it
|
||||
if (originalPromptContent !== null) {
|
||||
try {
|
||||
const current = await fsp.readFile(ABS_TARGET, 'utf8');
|
||||
if (current !== originalPromptContent) {
|
||||
await fsp.writeFile(ABS_TARGET, originalPromptContent);
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Remove test proposals written during tests
|
||||
for (const id of createdProposalIds) {
|
||||
const dir = path.join(PROPOSALS_DIR, id);
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Remove test archives written during tests (keep real ones, only delete test-* prefixed)
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET));
|
||||
try {
|
||||
const files = await fsp.readdir(archiveDir);
|
||||
for (const f of files) {
|
||||
if (f.startsWith('__test__')) {
|
||||
fs.rmSync(path.join(archiveDir, f), { force: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-proposal-create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-proposal-create: createProposal writes meta.json, new.md, change.diff', async () => {
|
||||
// Given: a valid target path and new content
|
||||
const newContent = originalPromptContent + '\n<!-- test marker -->';
|
||||
const agent = '__test-agent__';
|
||||
|
||||
// When: a proposal is created
|
||||
const { id } = await createProposal({
|
||||
agent,
|
||||
target: TEST_TARGET,
|
||||
newContent,
|
||||
reason: 'Test proposal for SCN-proposal-create',
|
||||
});
|
||||
|
||||
createdProposalIds.push(id);
|
||||
|
||||
// Then: the proposal directory contains the required files
|
||||
const proposalDir = path.join(PROPOSALS_DIR, id);
|
||||
assert.ok(fs.existsSync(path.join(proposalDir, 'meta.json')), 'meta.json exists');
|
||||
assert.ok(fs.existsSync(path.join(proposalDir, 'new.md')), 'new.md exists');
|
||||
assert.ok(fs.existsSync(path.join(proposalDir, 'change.diff')), 'change.diff exists');
|
||||
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(proposalDir, 'meta.json'), 'utf8'));
|
||||
assert.equal(meta.author, agent, 'author recorded');
|
||||
assert.equal(meta.target, TEST_TARGET, 'target recorded');
|
||||
assert.equal(meta.status, 'pending', 'initial status is pending');
|
||||
assert.equal(meta.reason, 'Test proposal for SCN-proposal-create', 'reason recorded');
|
||||
|
||||
const savedContent = fs.readFileSync(path.join(proposalDir, 'new.md'), 'utf8');
|
||||
assert.equal(savedContent, newContent, 'new.md contains proposed content');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-proposal-list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-proposal-list: listProposals returns created proposal; status filter works', async () => {
|
||||
// Given: at least one pending proposal exists (created in previous test)
|
||||
// When: listing all proposals
|
||||
const all = await listProposals();
|
||||
assert.ok(Array.isArray(all), 'listProposals returns an array');
|
||||
assert.ok(all.length >= 1, 'at least one proposal returned');
|
||||
|
||||
const testProposals = all.filter(p => p.author === '__test-agent__');
|
||||
assert.ok(testProposals.length >= 1, 'test proposal is in the list');
|
||||
assert.equal(testProposals[0].status, 'pending', 'status is pending');
|
||||
|
||||
// When: filtering by status that returns nothing
|
||||
const rejected = all.filter(p => p.status === 'rejected' && p.author === '__test-agent__');
|
||||
assert.equal(rejected.length, 0, 'no rejected proposals from our test agent yet');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-proposal-reject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-proposal-reject: rejectProposal marks proposal with rejected status and reason', async () => {
|
||||
// Given: an existing pending proposal
|
||||
const newContent = originalPromptContent + '\n<!-- reject-test -->';
|
||||
const { id } = await createProposal({
|
||||
agent: '__test-agent__',
|
||||
target: TEST_TARGET,
|
||||
newContent,
|
||||
reason: 'Will be rejected',
|
||||
});
|
||||
createdProposalIds.push(id);
|
||||
|
||||
// When: the proposal is rejected
|
||||
const rejectionReason = 'Does not meet EXIT CRITERIA standard';
|
||||
await rejectProposal(id, rejectionReason);
|
||||
|
||||
// Then: the meta.json reflects rejected status
|
||||
const proposal = await getProposal(id);
|
||||
assert.equal(proposal.meta.status, 'rejected', 'status is rejected');
|
||||
assert.equal(proposal.meta.rejectionReason, rejectionReason, 'rejection reason recorded');
|
||||
assert.ok(proposal.meta.rejectedAt, 'rejectedAt timestamp set');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-archive-naming: applyProposal creates <base>_<ts>_<hash>.md + .meta.json sidecar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-archive-naming: applyProposal archives with timestamp_hash filename and sidecar', async () => {
|
||||
// Given: a valid pending proposal for a real prompt file
|
||||
const newContent = originalPromptContent.replace(
|
||||
/^(# )/m,
|
||||
'# [VERSIONING TEST MARKER] '
|
||||
);
|
||||
// Ensure newContent still passes contract validation
|
||||
const hasContract = /Shared contract \(required\):/i.test(newContent) &&
|
||||
/Required startup \+ artifacts \+ memory \+ issue capture/i.test(newContent);
|
||||
if (!hasContract) {
|
||||
// Skip: the prompt doesn't have required headers; not a governance test failure
|
||||
return;
|
||||
}
|
||||
|
||||
const { id } = await createProposal({
|
||||
agent: '__test-agent__',
|
||||
target: TEST_TARGET,
|
||||
newContent,
|
||||
reason: 'Archive naming test',
|
||||
});
|
||||
createdProposalIds.push(id);
|
||||
|
||||
const expectedHash = createHash('sha256').update(originalPromptContent).digest('hex');
|
||||
|
||||
// When: the proposal is applied
|
||||
await applyProposal(id);
|
||||
|
||||
// Then: find the archive whose sidecar references this specific proposal ID
|
||||
// (There may be archives from prior test runs with the same hash; we must find ours.)
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET));
|
||||
const baseName = path.basename(TEST_TARGET, '.md');
|
||||
const files = fs.readdirSync(archiveDir);
|
||||
const sidecarFiles = files.filter(f => f.startsWith(baseName + '_') && f.endsWith('.meta.json'));
|
||||
|
||||
let archiveFile = null;
|
||||
let sidecar = null;
|
||||
for (const sf of sidecarFiles) {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(path.join(archiveDir, sf), 'utf8'));
|
||||
if (s.proposalId === id) {
|
||||
sidecar = s;
|
||||
archiveFile = sf.replace('.meta.json', '.md');
|
||||
break;
|
||||
}
|
||||
} catch { /* skip unreadable sidecars */ }
|
||||
}
|
||||
|
||||
assert.ok(archiveFile, `archive sidecar found for proposal ${id}`);
|
||||
assert.ok(fs.existsSync(path.join(archiveDir, archiveFile)), 'archive .md file exists');
|
||||
|
||||
// Filename must contain a timestamp segment (not just hash)
|
||||
const withoutPrefix = archiveFile.slice(baseName.length + 1, -3); // strip base_ and .md
|
||||
const parts = withoutPrefix.split('_');
|
||||
assert.ok(parts.length >= 2, 'filename has timestamp + hash parts');
|
||||
|
||||
assert.equal(sidecar.proposalId, id, 'sidecar references the proposal ID');
|
||||
assert.equal(sidecar.author, '__test-agent__', 'sidecar records author');
|
||||
assert.equal(sidecar.hash, expectedHash, 'sidecar records SHA-256 hash');
|
||||
assert.ok(sidecar.archivedAt, 'sidecar records archivedAt timestamp');
|
||||
|
||||
// Applied content is the new content
|
||||
const applied = fs.readFileSync(ABS_TARGET, 'utf8');
|
||||
assert.equal(applied, newContent, 'new content was written to target file');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-version-list: listPromptVersions returns metadata sorted newest first
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-version-list: listPromptVersions returns versions newest-first with metadata', async () => {
|
||||
// Given: two manually created archive files with different timestamps
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(TEST_TARGET));
|
||||
await fsp.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const baseName = '__test__versioning-probe';
|
||||
const ts1 = '2026-01-10T00-00-00-000Z';
|
||||
const ts2 = '2026-01-20T00-00-00-000Z';
|
||||
const hash1 = 'aaa000';
|
||||
const hash2 = 'bbb111';
|
||||
|
||||
const file1 = `${baseName}_${ts1}_${hash1}.md`;
|
||||
const file2 = `${baseName}_${ts2}_${hash2}.md`;
|
||||
|
||||
fs.writeFileSync(path.join(archiveDir, file1), 'old version');
|
||||
fs.writeFileSync(path.join(archiveDir, file2), 'newer version');
|
||||
fs.writeFileSync(
|
||||
path.join(archiveDir, file2.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ proposalId: 'test-prop', author: 'test-agent', reason: 'v2', hash: hash2, archivedAt: '2026-01-20T00:00:00.000Z' })
|
||||
);
|
||||
|
||||
// When: listing versions for the probe target
|
||||
const probeTarget = `src/prompts/daily/${baseName}.md`;
|
||||
const versions = await listPromptVersions(probeTarget);
|
||||
|
||||
// Then: sorted newest first (ts2 > ts1)
|
||||
assert.ok(versions.length >= 2, 'at least 2 versions returned');
|
||||
assert.equal(versions[0].filename, file2, 'newest version is first');
|
||||
assert.equal(versions[1].filename, file1, 'older version is second');
|
||||
|
||||
// Metadata from sidecar is surfaced
|
||||
assert.equal(versions[0].author, 'test-agent', 'author from sidecar');
|
||||
assert.equal(versions[0].reason, 'v2', 'reason from sidecar');
|
||||
assert.equal(versions[0].hash, hash2, 'hash from sidecar');
|
||||
|
||||
// Version without sidecar still has hash extracted from filename
|
||||
assert.ok(versions[1].hash, 'hash extracted from filename for legacy entry');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-rollback-latest
|
||||
// NOTE: Uses a dedicated probe target (not an actual prompt) so there is no
|
||||
// interference from archives created by other tests or the scheduler.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Probe target: a path inside the allowed subpath structure but with a name
|
||||
// that will not conflict with real prompts. rollbackPrompt only needs to
|
||||
// write to this path; the file is cleaned up in after().
|
||||
const ROLLBACK_PROBE_TARGET = 'src/prompts/daily/__rollback-probe__.md';
|
||||
const ABS_ROLLBACK_PROBE = path.join(CWD, ROLLBACK_PROBE_TARGET);
|
||||
|
||||
test('SCN-rollback-latest: rollbackPrompt restores the most recently archived content', async () => {
|
||||
// Given: two manually created archive files for the probe target; newest is distinctive
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(ROLLBACK_PROBE_TARGET));
|
||||
await fsp.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const baseName = path.basename(ROLLBACK_PROBE_TARGET, '.md');
|
||||
const olderTs = '2025-06-01T00-00-00-000Z';
|
||||
const newerTs = '2025-12-01T00-00-00-000Z';
|
||||
const olderHash = '__older-hash-scn-latest__';
|
||||
const newerHash = '__newer-hash-scn-latest__';
|
||||
const olderContent = '<!-- SCN-rollback-latest: older -->';
|
||||
const newerContent = '<!-- SCN-rollback-latest: newer -->';
|
||||
|
||||
const olderFile = `${baseName}_${olderTs}_${olderHash}.md`;
|
||||
const newerFile = `${baseName}_${newerTs}_${newerHash}.md`;
|
||||
|
||||
await fsp.writeFile(path.join(archiveDir, olderFile), olderContent);
|
||||
await fsp.writeFile(path.join(archiveDir, olderFile.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ archivedAt: '2025-06-01T00:00:00.000Z', hash: olderHash }));
|
||||
await fsp.writeFile(path.join(archiveDir, newerFile), newerContent);
|
||||
await fsp.writeFile(path.join(archiveDir, newerFile.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ archivedAt: '2025-12-01T00:00:00.000Z', hash: newerHash }));
|
||||
|
||||
try {
|
||||
// When: rolling back with 'latest' strategy
|
||||
const result = await rollbackPrompt(ROLLBACK_PROBE_TARGET, 'latest');
|
||||
|
||||
// Then: target file contains the newer archive's content
|
||||
assert.ok(result.success, 'rollback reported success');
|
||||
assert.equal(result.source, 'archive', 'restored from archive, not git');
|
||||
assert.equal(result.restored, newerFile, 'restored from newer archive');
|
||||
|
||||
const restoredContent = await fsp.readFile(ABS_ROLLBACK_PROBE, 'utf8');
|
||||
assert.equal(restoredContent, newerContent, 'target file contains newer archived content');
|
||||
} finally {
|
||||
// Cleanup probe artifacts
|
||||
for (const f of [olderFile, olderFile.replace('.md', '.meta.json'),
|
||||
newerFile, newerFile.replace('.md', '.meta.json')]) {
|
||||
try { await fsp.unlink(path.join(archiveDir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
try { await fsp.unlink(ABS_ROLLBACK_PROBE); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCN-rollback-hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SCN-rollback-hash: rollbackPrompt --strategy <fragment> restores matching archive', async () => {
|
||||
// Given: one archive with a distinctive hash fragment for the probe target
|
||||
const archiveDir = path.join(HISTORY_DIR, path.dirname(ROLLBACK_PROBE_TARGET));
|
||||
await fsp.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
const baseName = path.basename(ROLLBACK_PROBE_TARGET, '.md');
|
||||
const specificHash = 'cafecafe1234scn';
|
||||
const specificContent = '<!-- SCN-rollback-hash: specific content -->';
|
||||
const specificTs = '2025-09-15T00-00-00-000Z';
|
||||
const specificFile = `${baseName}_${specificTs}_${specificHash}.md`;
|
||||
|
||||
await fsp.writeFile(path.join(archiveDir, specificFile), specificContent);
|
||||
await fsp.writeFile(path.join(archiveDir, specificFile.replace('.md', '.meta.json')),
|
||||
JSON.stringify({ archivedAt: '2025-09-15T00:00:00.000Z', hash: specificHash }));
|
||||
|
||||
try {
|
||||
// When: rolling back with the specific hash fragment
|
||||
const result = await rollbackPrompt(ROLLBACK_PROBE_TARGET, 'cafecafe1234scn');
|
||||
|
||||
// Then: the correct archive was selected
|
||||
assert.ok(result.success, 'rollback succeeded');
|
||||
assert.equal(result.source, 'archive', 'restored from archive');
|
||||
assert.equal(result.restored, specificFile, 'correct archive file selected');
|
||||
|
||||
const restoredContent = await fsp.readFile(ABS_ROLLBACK_PROBE, 'utf8');
|
||||
assert.equal(restoredContent, specificContent, 'content matches the specific archive');
|
||||
} finally {
|
||||
for (const f of [specificFile, specificFile.replace('.md', '.meta.json')]) {
|
||||
try { await fsp.unlink(path.join(archiveDir, f)); } catch { /* ignore */ }
|
||||
}
|
||||
try { await fsp.unlink(ABS_ROLLBACK_PROBE); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdInit } from '../src/ops.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const TEST_DIR = 'test_validation_env';
|
||||
|
||||
test('cmdInit should validate install directory name', async (t) => {
|
||||
if (fs.existsSync(TEST_DIR)) {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(TEST_DIR);
|
||||
|
||||
const mockPkg = { name: "host", scripts: {} };
|
||||
fs.writeFileSync(path.join(TEST_DIR, 'package.json'), JSON.stringify(mockPkg));
|
||||
|
||||
await t.test('should reject directory with semicolon', async () => {
|
||||
await assert.rejects(async () => {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'torch; echo pwned',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
}, /Invalid directory name/);
|
||||
});
|
||||
|
||||
await t.test('should reject directory with spaces', async () => {
|
||||
await assert.rejects(async () => {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'torch dir',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
}, /Invalid directory name/);
|
||||
});
|
||||
|
||||
await t.test('should reject directory with quotes', async () => {
|
||||
await assert.rejects(async () => {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'torch"dir',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
}, /Invalid directory name/);
|
||||
});
|
||||
|
||||
await t.test('should reject directory with backticks', async () => {
|
||||
await assert.rejects(async () => {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'torch`dir',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
}, /Invalid directory name/);
|
||||
});
|
||||
|
||||
await t.test('should reject directory with $', async () => {
|
||||
await assert.rejects(async () => {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'torch$dir',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
}, /Invalid directory name/);
|
||||
});
|
||||
|
||||
await t.test('should accept valid directory names', async () => {
|
||||
try {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'valid-dir_123',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
} catch (e) {
|
||||
// It might fail because of other initialization steps, but shouldn't fail validation
|
||||
assert.notMatch(e.message, /Invalid directory name/);
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('should accept "."', async () => {
|
||||
try {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: '.',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
} catch (e) {
|
||||
assert.notMatch(e.message, /Invalid directory name/);
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('should accept nested paths with slashes', async () => {
|
||||
try {
|
||||
await cmdInit(false, TEST_DIR, {
|
||||
installDir: 'path/to/torch',
|
||||
namespace: 'ns',
|
||||
relays: []
|
||||
});
|
||||
} catch (e) {
|
||||
assert.notMatch(e.message, /Invalid directory name/);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if (fs.existsSync(TEST_DIR)) {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,415 +0,0 @@
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdCheck, cmdLock, cmdList, cmdComplete } from '../src/lib.mjs';
|
||||
|
||||
describe('src/lib.mjs', () => {
|
||||
// Common mocks
|
||||
const mockRelays = ['wss://relay.test'];
|
||||
const mockNamespace = 'test-ns';
|
||||
const mockTtl = 3600;
|
||||
const mockRoster = ['agent1', 'agent2', 'agent3'];
|
||||
const mockDateStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let logs = [];
|
||||
let errors = [];
|
||||
|
||||
const mockLog = (...args) => logs.push(args.join(' '));
|
||||
const mockError = (...args) => errors.push(args.join(' '));
|
||||
|
||||
const baseDeps = {
|
||||
getRelays: () => mockRelays,
|
||||
getNamespace: () => mockNamespace,
|
||||
getTtl: () => mockTtl,
|
||||
getRoster: () => mockRoster,
|
||||
loadTorchConfig: () => ({ scheduler: { paused: { daily: [], weekly: [] } } }),
|
||||
getDateStr: () => mockDateStr,
|
||||
log: mockLog,
|
||||
error: mockError,
|
||||
readdir: async () => [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
});
|
||||
|
||||
describe('cmdCheck', () => {
|
||||
it('returns correct structure with empty locks', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [],
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.strictEqual(result.namespace, mockNamespace);
|
||||
assert.strictEqual(result.cadence, 'daily');
|
||||
assert.strictEqual(result.date, mockDateStr);
|
||||
assert.deepStrictEqual(result.locked, []);
|
||||
assert.deepStrictEqual(result.available, mockRoster.sort());
|
||||
assert.strictEqual(result.lockCount, 0);
|
||||
});
|
||||
|
||||
it('identifies locked agents', async () => {
|
||||
const locks = [
|
||||
{ agent: 'agent1', eventId: 'ev1', createdAtIso: 'iso1', platform: 'test' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => locks,
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.deepStrictEqual(result.locked, ['agent1']);
|
||||
assert.deepStrictEqual(result.available, ['agent2', 'agent3']);
|
||||
assert.strictEqual(result.lockCount, 1);
|
||||
});
|
||||
|
||||
it('excludes paused agents', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [],
|
||||
loadTorchConfig: () => ({ scheduler: { paused: { daily: ['agent2'], weekly: [] } } }),
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.deepStrictEqual(result.paused, ['agent2']);
|
||||
assert.deepStrictEqual(result.excluded, ['agent2']);
|
||||
assert.deepStrictEqual(result.available, ['agent1', 'agent3']);
|
||||
});
|
||||
|
||||
it('identifies unknown locked agents', async () => {
|
||||
const locks = [
|
||||
{ agent: 'unknown-agent', eventId: 'ev1' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => locks,
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.deepStrictEqual(result.unknownLockedAgents, ['unknown-agent']);
|
||||
assert.deepStrictEqual(result.locked, ['unknown-agent']);
|
||||
// unknown agents are not in roster, so available should be full roster (since no roster agent is locked)
|
||||
assert.deepStrictEqual(result.available, mockRoster.sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmdLock', () => {
|
||||
const mockGenerateSecretKey = () => new Uint8Array(32);
|
||||
const mockGetPublicKey = () => 'mock-pubkey';
|
||||
const mockFinalizeEvent = (t) => ({ ...t, id: 'mock-id', sig: 'mock-sig' });
|
||||
|
||||
it('successfully locks an available agent', async () => {
|
||||
let published = false;
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [], // No existing locks, and race check returns empty
|
||||
publishLock: async () => { published = true; },
|
||||
generateSecretKey: mockGenerateSecretKey,
|
||||
getPublicKey: mockGetPublicKey,
|
||||
finalizeEvent: mockFinalizeEvent,
|
||||
raceCheckDelayMs: 1, // Fast race check
|
||||
};
|
||||
|
||||
const result = await cmdLock('agent1', 'daily', false, deps);
|
||||
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
assert.strictEqual(result.eventId, 'mock-id');
|
||||
assert.strictEqual(published, true);
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=ok/);
|
||||
});
|
||||
|
||||
it('fails if agent is not in roster', async () => {
|
||||
const deps = { ...baseDeps };
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdLock('unknown', 'daily', false, deps),
|
||||
(err) => err.code === 1 && err.message === 'Agent not in roster'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if agent is already locked', async () => {
|
||||
const existingLocks = [
|
||||
{ agent: 'agent1', eventId: 'existing', createdAt: 100, createdAtIso: 'time' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => existingLocks,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdLock('agent1', 'daily', false, deps),
|
||||
(err) => err.code === 3 && err.message === 'Lock denied'
|
||||
);
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=denied/);
|
||||
});
|
||||
|
||||
it('fails if agent is already completed', async () => {
|
||||
const existingLocks = [
|
||||
{ agent: 'agent1', eventId: 'done-id', status: 'completed' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => existingLocks,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdLock('agent1', 'daily', false, deps),
|
||||
(err) => err.code === 3 && err.message === 'Task already completed'
|
||||
);
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=denied/);
|
||||
assert.match(logs.join('\n'), /LOCK_REASON=already_completed/);
|
||||
});
|
||||
|
||||
it('fails if race check is lost', async () => {
|
||||
let callCount = 0;
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return []; // First check: no locks
|
||||
// Second check (race): found an earlier lock
|
||||
return [{
|
||||
agent: 'agent1',
|
||||
eventId: 'winner',
|
||||
createdAt: 100, // old timestamp
|
||||
createdAtIso: 'old-time'
|
||||
}, {
|
||||
agent: 'agent1',
|
||||
eventId: 'mock-id',
|
||||
createdAt: 200, // our timestamp (mocked event)
|
||||
}];
|
||||
},
|
||||
publishLock: async () => {},
|
||||
generateSecretKey: mockGenerateSecretKey,
|
||||
getPublicKey: mockGetPublicKey,
|
||||
finalizeEvent: mockFinalizeEvent,
|
||||
raceCheckDelayMs: 1,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdLock('agent1', 'daily', false, deps),
|
||||
(err) => err.code === 3 && err.message === 'Race check lost'
|
||||
);
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=race_lost/);
|
||||
});
|
||||
|
||||
it('handles race condition with identical timestamps using eventId tie-breaker (we win)', async () => {
|
||||
let callCount = 0;
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return [];
|
||||
return [{
|
||||
agent: 'agent1',
|
||||
eventId: 'aaaaa', // competitor (higher ID)
|
||||
createdAt: 100,
|
||||
createdAtIso: 'time'
|
||||
}, {
|
||||
agent: 'agent1',
|
||||
eventId: 'mock-id', // our ID (we need to ensure mockFinalizeEvent returns this or we control comparison)
|
||||
// The test mockFinalizeEvent returns 'mock-id'.
|
||||
// 'mock-id' > 'aaaaa'. So we would lose if competitor is 'aaaaa'.
|
||||
// To win, competitor must be 'zzzzz'.
|
||||
// Let's re-check the logic.
|
||||
// sort((a, b) => ... || a.id.localeCompare(b.id))
|
||||
// If we are 'mock-id', and competitor is 'zzzzz'.
|
||||
// 'mock-id'.localeCompare('zzzzz') is negative. So we come first.
|
||||
// racingLocks[0] is us. We win.
|
||||
createdAt: 100,
|
||||
}];
|
||||
},
|
||||
publishLock: async () => {},
|
||||
generateSecretKey: mockGenerateSecretKey,
|
||||
getPublicKey: mockGetPublicKey,
|
||||
finalizeEvent: (t) => ({ ...t, id: 'mock-id' }),
|
||||
raceCheckDelayMs: 1,
|
||||
};
|
||||
|
||||
// Competitor is 'aaaaa'. We are 'mock-id'. 'aaaaa' comes first. We lose.
|
||||
// Wait, I want a test where we WIN.
|
||||
// So competitor should be 'zzzzz'.
|
||||
deps.queryLocks = async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return [];
|
||||
return [{
|
||||
agent: 'agent1',
|
||||
eventId: 'zzzzz', // competitor (higher ID)
|
||||
createdAt: 100,
|
||||
createdAtIso: 'time'
|
||||
}, {
|
||||
agent: 'agent1',
|
||||
eventId: 'mock-id', // our ID
|
||||
createdAt: 100,
|
||||
}];
|
||||
};
|
||||
|
||||
const result = await cmdLock('agent1', 'daily', false, deps);
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=ok/);
|
||||
});
|
||||
|
||||
it('handles race condition with identical timestamps using eventId tie-breaker (we lose)', async () => {
|
||||
let callCount = 0;
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return [];
|
||||
return [{
|
||||
agent: 'agent1',
|
||||
eventId: 'aaaaa', // competitor (lower ID)
|
||||
createdAt: 100,
|
||||
createdAtIso: 'time'
|
||||
}, {
|
||||
agent: 'agent1',
|
||||
eventId: 'mock-id', // our ID ('m' > 'a')
|
||||
createdAt: 100,
|
||||
}];
|
||||
},
|
||||
publishLock: async () => {},
|
||||
generateSecretKey: mockGenerateSecretKey,
|
||||
getPublicKey: mockGetPublicKey,
|
||||
finalizeEvent: (t) => ({ ...t, id: 'mock-id' }),
|
||||
raceCheckDelayMs: 1,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdLock('agent1', 'daily', false, deps),
|
||||
(err) => err.code === 3 && err.message === 'Race check lost'
|
||||
);
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=race_lost/);
|
||||
});
|
||||
|
||||
it('dry run does not publish', async () => {
|
||||
let published = false;
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [],
|
||||
publishLock: async () => { published = true; },
|
||||
generateSecretKey: mockGenerateSecretKey,
|
||||
getPublicKey: mockGetPublicKey,
|
||||
finalizeEvent: mockFinalizeEvent,
|
||||
};
|
||||
|
||||
const result = await cmdLock('agent1', 'daily', true, deps);
|
||||
|
||||
assert.strictEqual(result.status, 'ok');
|
||||
assert.strictEqual(published, false);
|
||||
assert.match(errors.join('\n'), /\[DRY RUN\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmdList', () => {
|
||||
it('lists active locks', async () => {
|
||||
const locks = [
|
||||
{ agent: 'agent1', eventId: 'ev1', createdAt: Date.now()/1000 - 60, createdAtIso: 'iso1', platform: 'test' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => locks,
|
||||
};
|
||||
|
||||
await cmdList('daily', deps);
|
||||
|
||||
const output = logs.join('\n');
|
||||
assert.match(output, /Active test-ns daily locks/);
|
||||
assert.match(output, /agent1/);
|
||||
assert.match(output, /Locked: 1\/3/);
|
||||
});
|
||||
|
||||
it('warns about unknown agents', async () => {
|
||||
const locks = [
|
||||
{ agent: 'unknown', eventId: 'ev1', createdAt: Date.now()/1000 }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => locks,
|
||||
};
|
||||
|
||||
await cmdList('daily', deps);
|
||||
|
||||
const output = logs.join('\n');
|
||||
assert.match(output, /Warning: lock events found with non-roster agent names: unknown/);
|
||||
});
|
||||
|
||||
it('handles no locks', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [],
|
||||
};
|
||||
|
||||
await cmdList('daily', deps);
|
||||
|
||||
const output = logs.join('\n');
|
||||
assert.match(output, /\(no active locks\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmdComplete', () => {
|
||||
const mockGenerateSecretKey = () => new Uint8Array(32);
|
||||
const mockGetPublicKey = () => 'mock-pubkey';
|
||||
const mockFinalizeEvent = (t) => ({ ...t, id: 'mock-id-complete' });
|
||||
|
||||
it('successfully completes an active lock', async () => {
|
||||
let publishedEvent = null;
|
||||
const locks = [
|
||||
{ agent: 'agent1', eventId: 'lock-id', createdAt: 100, createdAtIso: 'iso1', status: 'started' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => locks,
|
||||
publishLock: async (relays, evt) => { publishedEvent = evt; },
|
||||
generateSecretKey: mockGenerateSecretKey,
|
||||
getPublicKey: mockGetPublicKey,
|
||||
finalizeEvent: mockFinalizeEvent,
|
||||
};
|
||||
|
||||
const result = await cmdComplete('agent1', 'daily', false, deps);
|
||||
|
||||
assert.strictEqual(result.status, 'completed');
|
||||
assert.strictEqual(result.eventId, 'mock-id-complete');
|
||||
assert.ok(publishedEvent);
|
||||
// Verify no expiration tag
|
||||
const expTag = publishedEvent.tags.find(t => t[0] === 'expiration');
|
||||
assert.strictEqual(expTag, undefined);
|
||||
// Verify content
|
||||
const content = JSON.parse(publishedEvent.content);
|
||||
assert.strictEqual(content.status, 'completed');
|
||||
assert.strictEqual(content.startedAt, 'iso1');
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=completed/);
|
||||
});
|
||||
|
||||
it('fails if no active lock found', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [], // No locks
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdComplete('agent1', 'daily', false, deps),
|
||||
(err) => err.code === 1 && err.message === 'No active lock found'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects already completed task', async () => {
|
||||
const locks = [
|
||||
{ agent: 'agent1', eventId: 'done-id', status: 'completed' }
|
||||
];
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => locks,
|
||||
};
|
||||
|
||||
const result = await cmdComplete('agent1', 'daily', false, deps);
|
||||
assert.strictEqual(result.status, 'completed');
|
||||
assert.strictEqual(result.eventId, 'done-id');
|
||||
assert.match(logs.join('\n'), /LOCK_STATUS=completed/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { publishLock, _resetRelayHealthState } from '../src/lock-ops.mjs';
|
||||
|
||||
const RELAYS = ['wss://relay-a', 'wss://relay-b', 'wss://relay-c'];
|
||||
|
||||
function parseTelemetry(lines) {
|
||||
return lines.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function createDynamicPool(outcomeForAttempt) {
|
||||
let publishCallCount = 0;
|
||||
return {
|
||||
publish(relays) {
|
||||
publishCallCount += 1;
|
||||
return relays.map((relay) => {
|
||||
const outcome = outcomeForAttempt({
|
||||
relay,
|
||||
attempt: publishCallCount,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (outcome.ok) {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error(outcome.message));
|
||||
});
|
||||
},
|
||||
close() {},
|
||||
getPublishCallCount() {
|
||||
return publishCallCount;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ci-resilience lock acquisition under unstable relays', { concurrency: false }, () => {
|
||||
const healthStabilityOverrides = {
|
||||
failureThreshold: 999,
|
||||
rollingWindowSize: 100,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
_resetRelayHealthState();
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
it('recovers after all relays are down for an initial outage window', async () => {
|
||||
const telemetryLogs = [];
|
||||
const sleepCalls = [];
|
||||
const initialOutageMs = 3_000;
|
||||
|
||||
const pool = createDynamicPool(({ relay, nowMs }) => {
|
||||
if (nowMs < initialOutageMs) {
|
||||
return { ok: false, message: 'relay unavailable during outage' };
|
||||
}
|
||||
if (relay === 'wss://relay-c') {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, message: 'relay unavailable during outage recovery' };
|
||||
});
|
||||
|
||||
const result = await publishLock(RELAYS, { id: 'evt-outage-recovery' }, {
|
||||
poolFactory: () => pool,
|
||||
getPublishTimeoutMsFn: () => 25,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 8,
|
||||
retryBaseDelayMs: 1_000,
|
||||
retryCapDelayMs: 1_000,
|
||||
randomFn: () => 1,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
...healthStabilityOverrides,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-outage-recovery');
|
||||
assert.strictEqual(pool.getPublishCallCount(), 4);
|
||||
assert.deepStrictEqual(sleepCalls, [1_000, 1_000, 1_000]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const quorumMet = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(quorumMet);
|
||||
assert.strictEqual(quorumMet.publishAttempt, 4);
|
||||
assert.ok(quorumMet.totalElapsedMs >= initialOutageMs);
|
||||
assert.ok(quorumMet.totalElapsedMs < 6_000);
|
||||
|
||||
const retriesByRelay = telemetry
|
||||
.filter((entry) => entry.event === 'lock_publish_retry')
|
||||
.reduce((acc, entry) => {
|
||||
acc[entry.relayUrl] = (acc[entry.relayUrl] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
assert.strictEqual(retriesByRelay['wss://relay-a'], 3);
|
||||
assert.strictEqual(retriesByRelay['wss://relay-b'], 3);
|
||||
assert.strictEqual(retriesByRelay['wss://relay-c'], 3);
|
||||
});
|
||||
|
||||
it('survives intermittent timeout spikes and reaches quorum with bounded retries', async () => {
|
||||
const telemetryLogs = [];
|
||||
const sleepCalls = [];
|
||||
|
||||
const pool = createDynamicPool(({ relay, attempt }) => {
|
||||
if (relay === 'wss://relay-a') {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (relay === 'wss://relay-b' && (attempt === 1 || attempt === 3)) {
|
||||
return { ok: false, message: 'publish timed out after 25ms (spike)' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const result = await publishLock(['wss://relay-a', 'wss://relay-b'], { id: 'evt-timeout-spikes' }, {
|
||||
poolFactory: () => pool,
|
||||
getPublishTimeoutMsFn: () => 25,
|
||||
getMinSuccessfulRelayPublishesFn: () => 2,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
retryBaseDelayMs: 200,
|
||||
retryCapDelayMs: 200,
|
||||
randomFn: () => 1,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
...healthStabilityOverrides,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-timeout-spikes');
|
||||
assert.strictEqual(pool.getPublishCallCount(), 2);
|
||||
assert.deepStrictEqual(sleepCalls, [200]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-b');
|
||||
assert.strictEqual(retryEvents[0].errorCategory, 'publish_timeout');
|
||||
|
||||
const quorumMet = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(quorumMet);
|
||||
assert.strictEqual(quorumMet.publishAttempt, 2);
|
||||
assert.ok(quorumMet.totalElapsedMs <= 1_000);
|
||||
});
|
||||
|
||||
it('handles staggered relay recovery and never exceeds retry budget', async () => {
|
||||
const telemetryLogs = [];
|
||||
const sleepCalls = [];
|
||||
|
||||
const pool = createDynamicPool(({ relay, attempt }) => {
|
||||
if (attempt <= 2) {
|
||||
return { ok: false, message: 'connect timeout during outage' };
|
||||
}
|
||||
if (attempt === 3) {
|
||||
if (relay === 'wss://relay-a') return { ok: true };
|
||||
return { ok: false, message: 'connect timeout during staged recovery' };
|
||||
}
|
||||
if (relay === 'wss://relay-a' || relay === 'wss://relay-b') {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, message: 'relay unavailable' };
|
||||
});
|
||||
|
||||
const retryAttempts = 6;
|
||||
const result = await publishLock(RELAYS, { id: 'evt-staggered-recovery' }, {
|
||||
poolFactory: () => pool,
|
||||
getPublishTimeoutMsFn: () => 25,
|
||||
getMinSuccessfulRelayPublishesFn: () => 2,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts,
|
||||
retryBaseDelayMs: 300,
|
||||
retryCapDelayMs: 300,
|
||||
randomFn: () => 1,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
...healthStabilityOverrides,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-staggered-recovery');
|
||||
assert.strictEqual(pool.getPublishCallCount(), 4);
|
||||
assert.ok(pool.getPublishCallCount() <= retryAttempts);
|
||||
assert.deepStrictEqual(sleepCalls, [300, 300, 300]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const quorumMet = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(quorumMet);
|
||||
assert.strictEqual(quorumMet.publishAttempt, 4);
|
||||
assert.ok(quorumMet.totalElapsedMs < 3_000);
|
||||
|
||||
const retriesByRelay = telemetry
|
||||
.filter((entry) => entry.event === 'lock_publish_retry')
|
||||
.reduce((acc, entry) => {
|
||||
acc[entry.relayUrl] = (acc[entry.relayUrl] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
assert.strictEqual(retriesByRelay['wss://relay-a'], 2);
|
||||
assert.strictEqual(retriesByRelay['wss://relay-b'], 3);
|
||||
assert.strictEqual(retriesByRelay['wss://relay-c'], 3);
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { publishLock, _resetRelayHealthState } from '../src/lock-ops.mjs';
|
||||
|
||||
function createScriptedPool(scriptedOutcomesByAttempt, relaysSeen) {
|
||||
let publishCallCount = 0;
|
||||
return {
|
||||
publish: (relays) => {
|
||||
publishCallCount += 1;
|
||||
relaysSeen.push({ publishAttempt: publishCallCount, relays: [...relays] });
|
||||
const attemptOutcomes = scriptedOutcomesByAttempt[publishCallCount - 1] || {};
|
||||
return relays.map((relay) => {
|
||||
const outcome = attemptOutcomes[relay] || { ok: true };
|
||||
return outcome.ok ? Promise.resolve('ok') : Promise.reject(new Error(outcome.message));
|
||||
});
|
||||
},
|
||||
close: () => {},
|
||||
getPublishCallCount: () => publishCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
const parseTelemetry = (logs) => logs.map((line) => JSON.parse(line));
|
||||
|
||||
describe('publishLock retry behavior', { concurrency: false }, () => {
|
||||
beforeEach(() => {
|
||||
_resetRelayHealthState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
it('succeeds immediately on first attempt without retries', async () => {
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
const event = { id: 'evt-immediate' };
|
||||
const result = await publishLock(['wss://relay-a'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 1);
|
||||
assert.deepStrictEqual(relaysSeen, [{ publishAttempt: 1, relays: ['wss://relay-a'] }]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
assert.strictEqual(telemetry.filter((entry) => entry.event === 'lock_publish_retry').length, 0);
|
||||
const metEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(metEvent);
|
||||
assert.strictEqual(metEvent.publishAttempt, 1);
|
||||
});
|
||||
|
||||
it('retries a transient failure then succeeds within retry budget with bounded jitter', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const sleepCalls = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'connection reset by peer' } },
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
const result = await publishLock(['wss://relay-a'], { id: 'evt-retry-success' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 100,
|
||||
retryCapDelayMs: 500,
|
||||
randomFn: () => 0.25,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-retry-success');
|
||||
assert.deepStrictEqual(sleepCalls, [25]);
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 2);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 1);
|
||||
assert.strictEqual(retryEvents[0].publishAttempt, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[0].nextDelayMs >= 0 && retryEvents[0].nextDelayMs <= 100);
|
||||
});
|
||||
|
||||
it('returns terminal relay_publish_quorum_failure after exhausting retry budget', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const sleepCalls = [];
|
||||
const randomValues = [0.1, 0.9];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
{ 'wss://relay-a': { ok: false, message: 'relay unavailable' } },
|
||||
], relaysSeen);
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://relay-a'], { id: 'evt-exhausted' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 100,
|
||||
retryCapDelayMs: 250,
|
||||
randomFn: () => randomValues.shift() ?? 0,
|
||||
sleepFn: async (delayMs) => {
|
||||
sleepCalls.push(delayMs);
|
||||
mock.timers.tick(delayMs);
|
||||
},
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_quorum_failure/,
|
||||
);
|
||||
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 3);
|
||||
assert.deepStrictEqual(sleepCalls, [10, 180]);
|
||||
assert.deepStrictEqual(relaysSeen.map((entry) => entry.publishAttempt), [1, 2, 3]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.strictEqual(retryEvents[0].publishAttempt, 1);
|
||||
assert.strictEqual(retryEvents[0].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[0].nextDelayMs >= 0 && retryEvents[0].nextDelayMs <= 100);
|
||||
assert.strictEqual(retryEvents[1].publishAttempt, 2);
|
||||
assert.strictEqual(retryEvents[1].relayUrl, 'wss://relay-a');
|
||||
assert.ok(retryEvents[1].nextDelayMs >= retryEvents[0].nextDelayMs);
|
||||
assert.ok(retryEvents[1].nextDelayMs <= 200);
|
||||
|
||||
const failedEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(failedEvent);
|
||||
assert.strictEqual(failedEvent.errorCategory, 'relay_publish_quorum_failure');
|
||||
assert.strictEqual(failedEvent.attempts, 3);
|
||||
});
|
||||
|
||||
it('short-circuits non-retryable failures without extra attempts', async () => {
|
||||
const telemetryLogs = [];
|
||||
const relaysSeen = [];
|
||||
const mockPool = createScriptedPool([
|
||||
{ 'wss://relay-a': { ok: false, message: 'validation failed: invalid signature' } },
|
||||
{ 'wss://relay-a': { ok: true } },
|
||||
], relaysSeen);
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://relay-a'], { id: 'evt-permanent' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_non_retryable/,
|
||||
);
|
||||
|
||||
assert.strictEqual(mockPool.getPublishCallCount(), 1);
|
||||
assert.deepStrictEqual(relaysSeen, [{ publishAttempt: 1, relays: ['wss://relay-a'] }]);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
assert.strictEqual(telemetry.filter((entry) => entry.event === 'lock_publish_retry').length, 0);
|
||||
|
||||
const failedEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(failedEvent);
|
||||
assert.strictEqual(failedEvent.errorCategory, 'relay_publish_non_retryable');
|
||||
assert.notStrictEqual(failedEvent.errorCategory, 'relay_publish_quorum_failure');
|
||||
});
|
||||
});
|
||||
@@ -1,432 +0,0 @@
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
parseLockEvent,
|
||||
publishLock,
|
||||
queryLocks,
|
||||
_resetRelayHealthState,
|
||||
} from '../src/lock-ops.mjs';
|
||||
|
||||
describe('lock-ops', () => {
|
||||
beforeEach(() => {
|
||||
_resetRelayHealthState();
|
||||
});
|
||||
|
||||
describe('parseLockEvent', () => {
|
||||
it('parses a valid lock event correctly', () => {
|
||||
const event = {
|
||||
id: 'mock-event-id',
|
||||
pubkey: 'mock-pubkey',
|
||||
created_at: 1678886400,
|
||||
tags: [['d', 'mock-d-tag'], ['expiration', '1678890000']],
|
||||
content: JSON.stringify({
|
||||
agent: 'test-agent',
|
||||
cadence: 'daily',
|
||||
status: 'started',
|
||||
date: '2023-03-15',
|
||||
platform: 'test-platform',
|
||||
}),
|
||||
};
|
||||
|
||||
const result = parseLockEvent(event);
|
||||
|
||||
assert.strictEqual(result.dTag, 'mock-d-tag');
|
||||
assert.strictEqual(result.expiresAt, 1678890000);
|
||||
assert.strictEqual(result.agent, 'test-agent');
|
||||
assert.strictEqual(result.platform, 'test-platform');
|
||||
});
|
||||
|
||||
it('returns nulls when content is invalid JSON', () => {
|
||||
const event = {
|
||||
id: 'mock-id',
|
||||
pubkey: 'mock-pubkey',
|
||||
created_at: 1678886400,
|
||||
tags: [],
|
||||
content: 'invalid json',
|
||||
};
|
||||
const result = parseLockEvent(event);
|
||||
assert.strictEqual(result.agent, null);
|
||||
assert.strictEqual(result.cadence, null);
|
||||
});
|
||||
|
||||
it('returns nulls when required content fields are missing', () => {
|
||||
const event = {
|
||||
id: 'mock-id',
|
||||
pubkey: 'mock-pubkey',
|
||||
created_at: 1678886400,
|
||||
tags: [],
|
||||
content: JSON.stringify({}),
|
||||
};
|
||||
const result = parseLockEvent(event);
|
||||
assert.strictEqual(result.agent, null);
|
||||
assert.strictEqual(result.cadence, null);
|
||||
});
|
||||
|
||||
it('returns nulls when content is an array (not object)', () => {
|
||||
const event = {
|
||||
id: 'mock-id',
|
||||
pubkey: 'mock-pubkey',
|
||||
created_at: 1678886400,
|
||||
tags: [],
|
||||
content: JSON.stringify(['not', 'an', 'object']),
|
||||
};
|
||||
const result = parseLockEvent(event);
|
||||
assert.strictEqual(result.agent, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryLocks', () => {
|
||||
it('falls back to fallback relays when primary query fails', async () => {
|
||||
const mockPool = {
|
||||
querySync: async (relays) => {
|
||||
if (relays.includes('wss://primary')) {
|
||||
throw new Error('primary unavailable');
|
||||
}
|
||||
return [{
|
||||
id: 'id-1',
|
||||
pubkey: 'pk',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['d', 't']],
|
||||
content: JSON.stringify({ agent: 'agent1' }),
|
||||
}];
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const locks = await queryLocks(
|
||||
['wss://primary'],
|
||||
'daily',
|
||||
'2026-02-14',
|
||||
'torch',
|
||||
{
|
||||
poolFactory: () => mockPool,
|
||||
getQueryTimeoutMsFn: () => 200,
|
||||
getRelayFallbacksFn: () => ['wss://fallback'],
|
||||
errorLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(locks.length, 1);
|
||||
assert.strictEqual(locks[0].agent, 'agent1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishLock', () => {
|
||||
it('retries transient failures and succeeds on a later attempt', async () => {
|
||||
const telemetry = [];
|
||||
const sleepCalls = [];
|
||||
let publishCalls = 0;
|
||||
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
if (publishCalls === 1) {
|
||||
return relays.map(() => Promise.reject(new Error('connection reset by peer')));
|
||||
}
|
||||
return relays.map(() => Promise.resolve('ok'));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-retry' };
|
||||
const result = await publishLock(['wss://primary-a', 'wss://primary-b'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
retryBaseDelayMs: 500,
|
||||
retryCapDelayMs: 8_000,
|
||||
randomFn: () => 0.5,
|
||||
sleepFn: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
telemetryLogger: (line) => {
|
||||
telemetry.push(JSON.parse(line));
|
||||
},
|
||||
healthLogger: () => {},
|
||||
diagnostics: {
|
||||
attemptId: 'attempt-1',
|
||||
correlationId: 'corr-1',
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 2);
|
||||
assert.deepStrictEqual(sleepCalls, [250]);
|
||||
|
||||
const retryEvents = telemetry.filter((entry) => entry.event === 'lock_publish_retry');
|
||||
assert.strictEqual(retryEvents.length, 2);
|
||||
assert.deepStrictEqual(retryEvents[0], {
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-a',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: retryEvents[0].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.deepStrictEqual(retryEvents[1], {
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: 'corr-1',
|
||||
attemptId: 'attempt-1',
|
||||
publishAttempt: 1,
|
||||
relayUrl: 'wss://primary-b',
|
||||
errorCategory: 'connection_reset',
|
||||
elapsedMs: retryEvents[1].elapsedMs,
|
||||
nextDelayMs: 250,
|
||||
});
|
||||
assert.ok(Number.isInteger(retryEvents[0].elapsedMs));
|
||||
assert.ok(Number.isInteger(retryEvents[1].elapsedMs));
|
||||
|
||||
const metEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(metEvent);
|
||||
assert.equal(metEvent.correlationId, 'corr-1');
|
||||
assert.equal(metEvent.attemptId, 'attempt-1');
|
||||
assert.equal(metEvent.publishAttempt, 2);
|
||||
assert.ok(Array.isArray(metEvent.retryTimeline));
|
||||
});
|
||||
|
||||
it('fails after retry budget is exhausted for persistent transient failures', async () => {
|
||||
let publishCalls = 0;
|
||||
const sleepCalls = [];
|
||||
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map(() => Promise.reject(new Error('relay unavailable')));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://primary-down'], { id: 'evt-exhausted' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
randomFn: () => 1,
|
||||
sleepFn: async (ms) => {
|
||||
sleepCalls.push(ms);
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=3,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 3);
|
||||
assert.deepStrictEqual(sleepCalls, [500, 1000]);
|
||||
});
|
||||
|
||||
it('fails without retry for persistent validation failures', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map(() => Promise.reject(new Error('validation failed: invalid signature')));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://primary-bad'], { id: 'evt-permanent' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 4,
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep for permanent failures');
|
||||
},
|
||||
telemetryLogger: () => {},
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/Failed relay publish quorum in publish phase: 0\/1 successful \(required=1, timeout=200ms, attempts=4,/,
|
||||
);
|
||||
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('includes per-relay reason categories and retry metadata in failure diagnostics', async () => {
|
||||
const telemetry = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
if (relay.includes('dns')) return Promise.reject(new Error('getaddrinfo ENOTFOUND relay.example'));
|
||||
if (relay.includes('tcp')) return Promise.reject(new Error('connect ETIMEDOUT 1.2.3.4:443'));
|
||||
if (relay.includes('tls')) return Promise.reject(new Error('TLS handshake failure'));
|
||||
if (relay.includes('ws')) return Promise.reject(new Error('websocket: unexpected server response'));
|
||||
return Promise.reject(new Error('[publish:primary] Publish timed out after 200ms'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => publishLock(['wss://dns', 'wss://tcp', 'wss://tls', 'wss://ws', 'relay-timeout'], { id: 'evt-diag' }, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetry.push(JSON.parse(line)),
|
||||
healthLogger: () => {},
|
||||
diagnostics: { attemptId: 'attempt-42', correlationId: 'corr-42' },
|
||||
}),
|
||||
);
|
||||
|
||||
const summaryEvent = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(summaryEvent);
|
||||
const failureEvents = telemetry.filter((entry) => entry.event === 'lock_publish_failure');
|
||||
const failureText = failureEvents.map((entry) => `${entry.relayUrl}:${entry.reason}`).join('\n');
|
||||
|
||||
assert.match(failureText, /wss:\/\/dns:dns_resolution/);
|
||||
assert.match(failureText, /wss:\/\/tcp:tcp_connect_timeout/);
|
||||
assert.match(failureText, /wss:\/\/tls:tls_handshake/);
|
||||
assert.match(failureText, /wss:\/\/ws:websocket_open_failure/);
|
||||
assert.match(failureText, /relay-timeout:publish_timeout/);
|
||||
|
||||
|
||||
assert.deepStrictEqual(summaryEvent.reasonDistribution, {
|
||||
dns_resolution: 1,
|
||||
tcp_connect_timeout: 1,
|
||||
tls_handshake: 1,
|
||||
websocket_open_failure: 1,
|
||||
publish_timeout: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns success when mixed relay outcomes still satisfy quorum', async () => {
|
||||
let publishCalls = 0;
|
||||
const mockPool = {
|
||||
publish: (relays) => {
|
||||
publishCalls += 1;
|
||||
return relays.map((relay) => (
|
||||
relay === 'wss://relay-ok'
|
||||
? Promise.resolve('ok')
|
||||
: Promise.reject(new Error('validation failed: rejected event'))
|
||||
));
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-mixed' };
|
||||
const result = await publishLock(['wss://relay-ok', 'wss://relay-bad'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 3,
|
||||
sleepFn: async () => {
|
||||
throw new Error('should not sleep when quorum is satisfied');
|
||||
},
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
assert.strictEqual(publishCalls, 1);
|
||||
});
|
||||
|
||||
it('uses fallback relays to satisfy min success quorum', async () => {
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => (
|
||||
relay.includes('fallback-ok') ? Promise.resolve('ok') : Promise.reject(new Error('offline'))
|
||||
)),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-2' };
|
||||
const result = await publishLock(['wss://primary-bad'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => ['wss://fallback-ok'],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result, event);
|
||||
});
|
||||
|
||||
it('quarantines repeatedly failing relays and still reaches quorum with one healthy relay', async () => {
|
||||
const callOrder = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
callOrder.push(relay);
|
||||
if (relay === 'wss://healthy') {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error('offline'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-health' };
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 2,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
callOrder.length = 0;
|
||||
await publishLock(['wss://bad', 'wss://healthy'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 2,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(callOrder, ['wss://healthy']);
|
||||
});
|
||||
|
||||
it('uses fallback and min active pool to reintroduce quarantined relay when needed', async () => {
|
||||
const attempts = [];
|
||||
const mockPool = {
|
||||
publish: (relays) => relays.map((relay) => {
|
||||
attempts.push(relay);
|
||||
if (relay === 'wss://fallback-healthy') {
|
||||
return Promise.resolve('ok');
|
||||
}
|
||||
return Promise.reject(new Error('relay timeout'));
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
|
||||
const event = { id: 'evt-quorum' };
|
||||
await publishLock(['wss://primary-a', 'wss://primary-b'], event, {
|
||||
poolFactory: () => mockPool,
|
||||
getPublishTimeoutMsFn: () => 200,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => ['wss://fallback-healthy'],
|
||||
getMinActiveRelayPoolFn: () => 2,
|
||||
retryAttempts: 1,
|
||||
failureThreshold: 1,
|
||||
quarantineCooldownMs: 60_000,
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.ok(attempts.includes('wss://fallback-healthy'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { getCompletedAgents } from '../src/lock-utils.mjs';
|
||||
|
||||
test('getCompletedAgents', async (t) => {
|
||||
await t.test('returns empty set if directory read fails (non-ENOENT)', async () => {
|
||||
const deps = {
|
||||
readdir: async () => { throw new Error('Permission denied'); },
|
||||
getDateStr: () => '2026-02-24',
|
||||
getIsoWeek: () => '2026-W09'
|
||||
};
|
||||
const completed = await getCompletedAgents('daily', 'logs', deps);
|
||||
assert.strictEqual(completed.size, 0);
|
||||
});
|
||||
|
||||
await t.test('returns empty set if directory does not exist (ENOENT)', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
error.code = 'ENOENT';
|
||||
const deps = {
|
||||
readdir: async () => { throw error; },
|
||||
getDateStr: () => '2026-02-24',
|
||||
getIsoWeek: () => '2026-W09'
|
||||
};
|
||||
const completed = await getCompletedAgents('daily', 'logs', deps);
|
||||
assert.strictEqual(completed.size, 0);
|
||||
});
|
||||
|
||||
await t.test('identifies daily completed agents correctly', async () => {
|
||||
const files = [
|
||||
'2026-02-24T10-00-00Z__agent-a__completed.md',
|
||||
'2026-02-24T11-00-00Z__agent-b__failed.md', // failed, not completed
|
||||
'2026-02-23T10-00-00Z__agent-c__completed.md', // yesterday
|
||||
'invalid-format.md'
|
||||
];
|
||||
const deps = {
|
||||
readdir: async () => files,
|
||||
getDateStr: () => '2026-02-24',
|
||||
getIsoWeek: () => '2026-W09'
|
||||
};
|
||||
const completed = await getCompletedAgents('daily', 'logs', deps);
|
||||
assert.ok(completed.has('agent-a'));
|
||||
assert.ok(!completed.has('agent-b'));
|
||||
assert.ok(!completed.has('agent-c'));
|
||||
assert.strictEqual(completed.size, 1);
|
||||
});
|
||||
|
||||
await t.test('identifies weekly completed agents correctly', async () => {
|
||||
const files = [
|
||||
'2026-02-24T10-00-00Z__agent-a__completed.md', // this week (W09)
|
||||
'2026-02-17T10-00-00Z__agent-b__completed.md', // last week (W08)
|
||||
];
|
||||
const deps = {
|
||||
readdir: async () => files,
|
||||
getDateStr: () => '2026-02-24',
|
||||
getIsoWeek: (date) => {
|
||||
if (date === '2026-02-24') return '2026-W09';
|
||||
if (date === '2026-02-17') return '2026-W08';
|
||||
return '2026-W09'; // Default for current
|
||||
}
|
||||
};
|
||||
const completed = await getCompletedAgents('weekly', 'logs', deps);
|
||||
assert.ok(completed.has('agent-a'));
|
||||
assert.ok(!completed.has('agent-b'));
|
||||
assert.strictEqual(completed.size, 1);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { mock, describe, it, before, after, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
|
||||
// Mock process.exit
|
||||
const originalExit = process.exit;
|
||||
let exitMock;
|
||||
|
||||
// Mock console
|
||||
const originalError = console.error;
|
||||
let errorMock;
|
||||
const originalLog = console.log;
|
||||
let logMock;
|
||||
|
||||
describe('main dispatch', () => {
|
||||
let main;
|
||||
|
||||
before(async () => {
|
||||
exitMock = mock.fn();
|
||||
process.exit = exitMock;
|
||||
|
||||
errorMock = mock.fn();
|
||||
console.error = errorMock;
|
||||
|
||||
logMock = mock.fn();
|
||||
console.log = logMock;
|
||||
|
||||
// We import main
|
||||
// Since we cannot mock modules easily in this environment (mock.module missing),
|
||||
// we will rely on testing the error paths of the handlers, which don't trigger side effects.
|
||||
const mod = await import('../src/lib.mjs');
|
||||
main = mod.main;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.exit = originalExit;
|
||||
console.error = originalError;
|
||||
console.log = originalLog;
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
exitMock.mock.resetCalls();
|
||||
errorMock.mock.resetCalls();
|
||||
logMock.mock.resetCalls();
|
||||
});
|
||||
|
||||
it('should exit with code 1 if no command provided', async () => {
|
||||
await main([]);
|
||||
|
||||
assert.strictEqual(exitMock.mock.callCount(), 1);
|
||||
assert.strictEqual(exitMock.mock.calls[0].arguments[0], 1);
|
||||
});
|
||||
|
||||
it('should exit with code 1 if unknown command provided', async () => {
|
||||
await main(['unknown']);
|
||||
|
||||
assert.strictEqual(exitMock.mock.callCount(), 1);
|
||||
assert.strictEqual(exitMock.mock.calls[0].arguments[0], 1);
|
||||
const unknownMsg = errorMock.mock.calls.find(c => c.arguments[0].includes('Unknown command'));
|
||||
assert.ok(unknownMsg);
|
||||
});
|
||||
|
||||
it('should exit with code 1 if check command misses cadence', async () => {
|
||||
await main(['check']);
|
||||
|
||||
assert.strictEqual(exitMock.mock.callCount(), 1);
|
||||
assert.strictEqual(exitMock.mock.calls[0].arguments[0], 1);
|
||||
const missingMsg = errorMock.mock.calls.find(c => c.arguments[0].includes('required for check'));
|
||||
assert.ok(missingMsg);
|
||||
});
|
||||
|
||||
it('should exit with code 1 if lock command misses agent', async () => {
|
||||
await main(['lock']);
|
||||
|
||||
assert.strictEqual(exitMock.mock.callCount(), 1);
|
||||
assert.strictEqual(exitMock.mock.calls[0].arguments[0], 1);
|
||||
const missingMsg = errorMock.mock.calls.find(c => c.arguments[0].includes('required for lock'));
|
||||
assert.ok(missingMsg);
|
||||
});
|
||||
|
||||
it('should exit with code 1 if complete command misses agent', async () => {
|
||||
await main(['complete']);
|
||||
|
||||
assert.strictEqual(exitMock.mock.callCount(), 1);
|
||||
assert.strictEqual(exitMock.mock.calls[0].arguments[0], 1);
|
||||
const missingMsg = errorMock.mock.calls.find(c => c.arguments[0].includes('required for complete'));
|
||||
assert.ok(missingMsg);
|
||||
});
|
||||
|
||||
it('should exit with code 1 if proposal command misses subcommand', async () => {
|
||||
await main(['proposal']);
|
||||
|
||||
assert.strictEqual(exitMock.mock.callCount(), 1);
|
||||
assert.strictEqual(exitMock.mock.calls[0].arguments[0], 1);
|
||||
const missingMsg = errorMock.mock.calls.find(c => c.arguments[0].includes('Missing subcommand'));
|
||||
assert.ok(missingMsg);
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRelevantMemories,
|
||||
ingestEvents,
|
||||
inspectMemory,
|
||||
listMemories,
|
||||
markMemoryMerged,
|
||||
memoryStats,
|
||||
pinMemory,
|
||||
runPruneCycle,
|
||||
triggerPruneDryRun,
|
||||
unpinMemory,
|
||||
} from '../src/services/memory/index.js';
|
||||
|
||||
test('listMemories, inspectMemory, pinMemory, and unpinMemory support admin hooks', async () => {
|
||||
const records = [
|
||||
{ id: 'm1', agent_id: 'agent-a', type: 'event', tags: ['ops'], pinned: false, last_seen: 10, merged_into: null },
|
||||
{ id: 'm2', agent_id: 'agent-a', type: 'summary', tags: ['ops', 'prod'], pinned: true, last_seen: 20, merged_into: null },
|
||||
{ id: 'm3', agent_id: 'agent-b', type: 'event', tags: ['dev'], pinned: false, last_seen: 5, merged_into: 'm2' },
|
||||
];
|
||||
|
||||
const repository = {
|
||||
async listMemories() {
|
||||
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];
|
||||
},
|
||||
};
|
||||
|
||||
const listed = await listMemories({ agent_id: 'agent-a', tags: ['ops'], includeMerged: false }, { repository });
|
||||
assert.deepEqual(listed.map((entry) => entry.id), ['m2', 'm1']);
|
||||
|
||||
const inspected = await inspectMemory('m2', { repository });
|
||||
assert.equal(inspected?.id, 'm2');
|
||||
|
||||
const pinned = await pinMemory('m1', { repository });
|
||||
assert.equal(pinned?.pinned, true);
|
||||
|
||||
const unpinned = await unpinMemory('m2', { repository });
|
||||
assert.equal(unpinned?.pinned, false);
|
||||
});
|
||||
|
||||
test('triggerPruneDryRun returns candidate metadata without deleting records', async () => {
|
||||
const repository = {
|
||||
async listPruneCandidates() {
|
||||
return [
|
||||
{ id: 'old-1', agent_id: 'agent-z', type: 'event', pinned: false, last_seen: 1, merged_into: null },
|
||||
];
|
||||
},
|
||||
async listMemories() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const dryRun = await triggerPruneDryRun({ repository, retentionMs: 1000 });
|
||||
assert.equal(dryRun.dryRun, true);
|
||||
assert.equal(dryRun.candidateCount, 1);
|
||||
assert.deepEqual(dryRun.candidates[0], {
|
||||
id: 'old-1',
|
||||
agent_id: 'agent-z',
|
||||
type: 'event',
|
||||
pinned: false,
|
||||
last_seen: 1,
|
||||
merged_into: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('memoryStats reports counts/rates and retrieval telemetry remains redaction-safe', async () => {
|
||||
const telemetry = [];
|
||||
await ingestEvents([
|
||||
{
|
||||
agent_id: 'agent-telemetry',
|
||||
content: 'customer token ABC123 secret',
|
||||
timestamp: Date.now(),
|
||||
tags: ['incident'],
|
||||
metadata: { session_id: 's-telemetry' },
|
||||
},
|
||||
]);
|
||||
|
||||
const retrieved = await getRelevantMemories({
|
||||
agent_id: 'agent-telemetry',
|
||||
query: 'show customer token ABC123 secret',
|
||||
emitTelemetry: (event, payload) => telemetry.push({ event, payload }),
|
||||
});
|
||||
|
||||
assert.ok(retrieved.length >= 1);
|
||||
const retrievalEvent = telemetry.find((entry) => entry.event === 'memory:retrieved');
|
||||
assert.ok(retrievalEvent);
|
||||
assert.equal('query' in retrievalEvent.payload, false);
|
||||
assert.equal(retrievalEvent.payload.query_present, true);
|
||||
assert.ok(retrievalEvent.payload.query_length > 0);
|
||||
|
||||
const stats = await memoryStats({ windowMs: 60 * 60 * 1000 });
|
||||
assert.ok(stats.totals.total >= 1);
|
||||
assert.ok(Number.isFinite(stats.rates.archivedRate));
|
||||
assert.ok(Number.isFinite(stats.rates.deletedRate));
|
||||
assert.ok(stats.indexSizeEstimateBytes >= 0);
|
||||
assert.ok(Number.isFinite(stats.ingestThroughputPerMinute));
|
||||
});
|
||||
|
||||
|
||||
test('runPruneCycle respects prune feature-flag modes', async () => {
|
||||
const repository = {
|
||||
async listPruneCandidates() {
|
||||
return [
|
||||
{ id: 'old-1', agent_id: 'agent-z', type: 'event', pinned: false, last_seen: 1, merged_into: null },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const dryRunResult = await runPruneCycle({
|
||||
repository,
|
||||
env: { TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' },
|
||||
retentionMs: 1000,
|
||||
});
|
||||
assert.equal(dryRunResult.mode, 'dry-run');
|
||||
assert.equal(dryRunResult.pruned.length, 0);
|
||||
assert.equal(dryRunResult.candidates.length, 1);
|
||||
|
||||
const offResult = await runPruneCycle({
|
||||
repository,
|
||||
env: { TORCH_MEMORY_PRUNE_ENABLED: 'false' },
|
||||
retentionMs: 1000,
|
||||
});
|
||||
assert.equal(offResult.mode, 'off');
|
||||
assert.equal(offResult.pruned.length, 0);
|
||||
});
|
||||
|
||||
test('markMemoryMerged delegates to repository and clears cache', async () => {
|
||||
let callCount = 0;
|
||||
const repository = {
|
||||
async markMerged(id, _mergedInto) {
|
||||
callCount++;
|
||||
return id === 'exists';
|
||||
}
|
||||
};
|
||||
|
||||
const result1 = await markMemoryMerged('exists', 'target', { repository });
|
||||
assert.equal(result1, true);
|
||||
assert.equal(callCount, 1);
|
||||
|
||||
const result2 = await markMemoryMerged('missing', 'target', { repository });
|
||||
assert.equal(result2, false);
|
||||
assert.equal(callCount, 2);
|
||||
|
||||
// Verify cache clearing
|
||||
const telemetry = [];
|
||||
const emitTelemetry = (event, payload) => telemetry.push({ event, payload });
|
||||
|
||||
// 1. Populate cache
|
||||
await getRelevantMemories({ agent_id: 'cache-test', emitTelemetry });
|
||||
const hit1 = telemetry.findLast(t => t.event === 'memory:retrieved');
|
||||
assert.equal(hit1.payload.cache_hit, false);
|
||||
|
||||
// 2. Hit cache
|
||||
await getRelevantMemories({ agent_id: 'cache-test', emitTelemetry });
|
||||
const hit2 = telemetry.findLast(t => t.event === 'memory:retrieved');
|
||||
assert.equal(hit2.payload.cache_hit, true);
|
||||
|
||||
// 3. Clear cache via side-effect
|
||||
await markMemoryMerged('exists', 'target', { repository });
|
||||
|
||||
// 4. Miss cache
|
||||
await getRelevantMemories({ agent_id: 'cache-test', emitTelemetry });
|
||||
const hit3 = telemetry.findLast(t => t.event === 'memory:retrieved');
|
||||
assert.equal(hit3.payload.cache_hit, false);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createMemoryCache } from '../src/services/memory/cache.js';
|
||||
|
||||
test('stores runtime events in signer/session namespace and clears scope', () => {
|
||||
const cache = createMemoryCache();
|
||||
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 });
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's2' }, { id: 2 });
|
||||
|
||||
assert.deepEqual(
|
||||
cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).map((event) => event.id),
|
||||
[1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's2' }).map((event) => event.id),
|
||||
[2],
|
||||
);
|
||||
|
||||
cache.clearScope({ signer_id: 'alice', session_id: 's1' });
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0);
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's2' }).length, 1);
|
||||
});
|
||||
|
||||
test('expires runtime events and records expiration metrics', () => {
|
||||
const originalNow = Date.now;
|
||||
try {
|
||||
const start = originalNow.call(Date);
|
||||
Date.now = () => start;
|
||||
|
||||
const cache = createMemoryCache();
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 }, 1);
|
||||
|
||||
// Advance mocked clock past the 1ms TTL
|
||||
Date.now = () => start + 10;
|
||||
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0);
|
||||
const metrics = cache.getMetrics();
|
||||
assert.equal(metrics.runtime_events_expired, 1);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('enforces LRU bounds and tracks eviction metrics', () => {
|
||||
const cache = createMemoryCache({ maxNamespaces: 1, maxEventsPerNamespace: 2, maxTotalEvents: 2 });
|
||||
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 });
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 2 });
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 3 });
|
||||
|
||||
const ids = cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).map((event) => event.id);
|
||||
assert.deepEqual(ids, [2, 3]);
|
||||
|
||||
cache.setRuntimeEvent({ signer_id: 'bob', session_id: 's2' }, { id: 4 });
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0);
|
||||
|
||||
const metrics = cache.getMetrics();
|
||||
assert.ok(metrics.runtime_events_lru_evicted >= 3);
|
||||
});
|
||||
|
||||
test('blocks decrypted/session-sensitive data from durable promotion without sanitizer pass', () => {
|
||||
const cache = createMemoryCache();
|
||||
|
||||
cache.setRuntimeEvent(
|
||||
{ signer_id: 'alice', session_id: 's1' },
|
||||
{ id: 1, classification: 'decrypted', sanitized_for_durable: false },
|
||||
);
|
||||
cache.setRuntimeEvent(
|
||||
{ signer_id: 'alice', session_id: 's1' },
|
||||
{ id: 2, classification: 'decrypted', sanitized_for_durable: true },
|
||||
);
|
||||
|
||||
const events = cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' });
|
||||
assert.equal(events[0].durable_eligible, false);
|
||||
assert.equal(events[1].durable_eligible, true);
|
||||
assert.equal(cache.getMetrics().runtime_sensitive_events_blocked, 1);
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createEmbedderAdapter, embedText } from '../src/services/memory/embedder.js';
|
||||
|
||||
test('createEmbedderAdapter selects in-memory backend and supports vector CRUD', async () => {
|
||||
const adapter = createEmbedderAdapter({ backend: 'inmemory' });
|
||||
// Manually provide a vector to verify storage mechanism works even if default embedder is disabled
|
||||
const vector = [0.1, 0.2, 0.3];
|
||||
|
||||
await adapter.upsertVector({
|
||||
id: 'vec-1',
|
||||
vector,
|
||||
metadata: { agent_id: 'a1', context_excerpt: 'hello world' },
|
||||
});
|
||||
|
||||
const results = await adapter.queryVector({
|
||||
vector,
|
||||
k: 1,
|
||||
filter: { agent_id: 'a1' },
|
||||
});
|
||||
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].id, 'vec-1');
|
||||
assert.equal(results[0].metadata.context_excerpt, 'hello world');
|
||||
|
||||
const deleted = await adapter.deleteVector('vec-1');
|
||||
assert.equal(deleted, true);
|
||||
});
|
||||
|
||||
test('createEmbedderAdapter reads backend from environment', async () => {
|
||||
process.env.MEMORY_VECTOR_BACKEND = 'local';
|
||||
const adapter = createEmbedderAdapter();
|
||||
const vector = await embedText('env selected', { adapter });
|
||||
|
||||
assert.ok(Array.isArray(vector));
|
||||
// Default embedding is disabled, so vector should be empty
|
||||
assert.equal(vector.length, 0);
|
||||
|
||||
delete process.env.MEMORY_VECTOR_BACKEND;
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getMemoryPruneMode,
|
||||
isMemoryEnabled,
|
||||
isMemoryIngestEnabled,
|
||||
isMemoryPruneEnabled,
|
||||
} from '../src/services/memory/feature-flags.js';
|
||||
|
||||
test('memory feature flags parse defaults and canary allow-lists', () => {
|
||||
assert.equal(isMemoryEnabled({}), true);
|
||||
assert.equal(isMemoryEnabled({ TORCH_MEMORY_ENABLED: 'false' }), false);
|
||||
|
||||
assert.equal(isMemoryIngestEnabled('agent-a', {}), true);
|
||||
assert.equal(isMemoryIngestEnabled('agent-a', { TORCH_MEMORY_INGEST_ENABLED: 'false' }), false);
|
||||
assert.equal(isMemoryIngestEnabled('agent-a', { TORCH_MEMORY_INGEST_ENABLED: 'agent-a,agent-b' }), true);
|
||||
assert.equal(isMemoryIngestEnabled('agent-c', { TORCH_MEMORY_INGEST_ENABLED: 'agent-a,agent-b' }), false);
|
||||
});
|
||||
|
||||
test('memory prune flag supports off, dry-run, and active modes', () => {
|
||||
assert.equal(getMemoryPruneMode({}), 'active');
|
||||
assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'false' }), 'off');
|
||||
assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }), 'dry-run');
|
||||
assert.equal(getMemoryPruneMode({ TORCH_MEMORY_PRUNE_ENABLED: 'true' }), 'active');
|
||||
assert.equal(isMemoryPruneEnabled({ TORCH_MEMORY_PRUNE_ENABLED: 'dry-run' }), false);
|
||||
assert.equal(isMemoryPruneEnabled({ TORCH_MEMORY_PRUNE_ENABLED: 'true' }), true);
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
estimateTokenCount,
|
||||
formatMemoryBlock,
|
||||
formatMemoriesForPrompt,
|
||||
renderPromptWithMemoryContext,
|
||||
} from '../src/services/memory/formatter.js';
|
||||
|
||||
function createMemory(overrides = {}) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
id: overrides.id ?? 'm1',
|
||||
agent_id: overrides.agent_id ?? 'agent-1',
|
||||
session_id: overrides.session_id ?? 's1',
|
||||
type: overrides.type ?? 'event',
|
||||
content: overrides.content ?? 'default content',
|
||||
summary: overrides.summary ?? 'default summary',
|
||||
tags: overrides.tags ?? [],
|
||||
importance: overrides.importance ?? 0.5,
|
||||
embedding_id: overrides.embedding_id ?? null,
|
||||
created_at: overrides.created_at ?? 1700000000000,
|
||||
last_seen: overrides.last_seen ?? 1700000000000,
|
||||
source: overrides.source ?? 'ingest',
|
||||
ttl_days: overrides.ttl_days ?? null,
|
||||
merged_into: overrides.merged_into ?? null,
|
||||
pinned: overrides.pinned ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test('estimateTokenCount returns heuristic count', () => {
|
||||
assert.equal(estimateTokenCount(''), 0);
|
||||
assert.equal(estimateTokenCount(null), 0);
|
||||
assert.equal(estimateTokenCount(undefined), 0);
|
||||
assert.equal(estimateTokenCount('abcd'), 1);
|
||||
assert.equal(estimateTokenCount('abcde'), 2);
|
||||
assert.equal(estimateTokenCount('A'.repeat(400)), 100);
|
||||
});
|
||||
|
||||
test('formatMemoryBlock converts memory record to display block', () => {
|
||||
const memory = createMemory({
|
||||
id: 'm1',
|
||||
summary: 'The quick brown fox',
|
||||
content: 'Jumps over the lazy dog',
|
||||
importance: 0.856,
|
||||
created_at: 1700000000000,
|
||||
tags: ['fox', 'dog']
|
||||
});
|
||||
|
||||
const block = formatMemoryBlock(memory);
|
||||
|
||||
assert.equal(block.id, 'm1');
|
||||
assert.equal(block.summary, 'The quick brown fox');
|
||||
assert.equal(block.short_context, 'Jumps over the lazy dog');
|
||||
assert.equal(block.importance, '0.86');
|
||||
assert.equal(block.timestamp, '2023-11-14T22:13:20.000Z');
|
||||
assert.equal(block.tags, 'fox,dog');
|
||||
});
|
||||
|
||||
test('formatMemoryBlock handles missing optional fields and default clamping', () => {
|
||||
const memory = {
|
||||
id: 'm-minimal',
|
||||
created_at: 1700000000000,
|
||||
};
|
||||
|
||||
const block = formatMemoryBlock(memory);
|
||||
|
||||
assert.equal(block.id, 'm-minimal');
|
||||
assert.equal(block.summary, '');
|
||||
assert.equal(block.short_context, '');
|
||||
assert.equal(block.importance, '0.00');
|
||||
assert.equal(block.tags, '');
|
||||
});
|
||||
|
||||
test('formatMemoryBlock respects custom clamping options', () => {
|
||||
const memory = createMemory({
|
||||
summary: '1234567890',
|
||||
content: 'abcdefghij',
|
||||
});
|
||||
|
||||
const block = formatMemoryBlock(memory, {
|
||||
maxSummaryChars: 5,
|
||||
maxContextChars: 3
|
||||
});
|
||||
|
||||
// clampString adds ellipsis if length > maxChars.
|
||||
// if length > maxChars, it takes maxChars - 1 and adds …
|
||||
assert.equal(block.summary, '1234…');
|
||||
assert.equal(block.short_context, 'ab…');
|
||||
});
|
||||
|
||||
test('formatMemoryBlock handles edge cases for clamping', () => {
|
||||
const memory = createMemory({
|
||||
summary: 'exact',
|
||||
content: 'over',
|
||||
});
|
||||
|
||||
// Exactly maxChars
|
||||
const blockExact = formatMemoryBlock(memory, { maxSummaryChars: 5, maxContextChars: 3 });
|
||||
assert.equal(blockExact.summary, 'exact');
|
||||
assert.equal(blockExact.short_context, 'ov…');
|
||||
|
||||
// Negative or zero maxChars
|
||||
const blockZero = formatMemoryBlock(memory, { maxSummaryChars: 0, maxContextChars: -1 });
|
||||
assert.equal(blockZero.summary, '');
|
||||
assert.equal(blockZero.short_context, '');
|
||||
});
|
||||
|
||||
test('formatMemoriesForPrompt enforces token budget and k limit', () => {
|
||||
const memories = [
|
||||
createMemory({ id: 'm1', summary: 'one', importance: 0.9 }),
|
||||
createMemory({ id: 'm2', summary: 'two', importance: 0.8 }),
|
||||
createMemory({ id: 'm3', summary: 'three', importance: 0.7 }),
|
||||
];
|
||||
|
||||
// Limit k to 2
|
||||
const resultK = formatMemoriesForPrompt(memories, { k: 2 });
|
||||
assert.equal(resultK.blocks.length, 2);
|
||||
assert.equal(resultK.blocks[0].id, 'm1');
|
||||
assert.equal(resultK.blocks[1].id, 'm2');
|
||||
assert.equal(resultK.truncated, false);
|
||||
|
||||
// Limit token budget
|
||||
// A single block line looks like: [m1, 2023-11-14T22:13:20.000Z, 0.90, one, default content, ]
|
||||
// Tokens (chars/4): ~70 chars -> 18 tokens
|
||||
const resultBudget = formatMemoriesForPrompt(memories, { tokenBudget: 25 });
|
||||
assert.equal(resultBudget.blocks.length, 1);
|
||||
assert.ok(resultBudget.usedTokens <= 25);
|
||||
assert.equal(resultBudget.truncated, true);
|
||||
});
|
||||
|
||||
test('formatMemoriesForPrompt handles aggressive budget fitting', () => {
|
||||
const memory = createMemory({
|
||||
id: 'm1',
|
||||
summary: 'very long summary that will need clamping'.repeat(10),
|
||||
content: 'very long content that will need clamping'.repeat(10),
|
||||
});
|
||||
|
||||
// Very small budget should force fitting
|
||||
const result = formatMemoriesForPrompt([memory], { tokenBudget: 15 });
|
||||
assert.equal(result.blocks.length, 1);
|
||||
assert.ok(result.usedTokens <= 15);
|
||||
assert.ok(result.blocks[0].summary.length <= 8 || result.blocks[0].summary.includes('…'));
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext injects context when enabled', async () => {
|
||||
const memoryService = {
|
||||
async getRelevantMemories() {
|
||||
return [createMemory({ id: 'm-ctx', summary: 'relevant info' })];
|
||||
},
|
||||
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.match(output, /\[CONTEXT — Relevant memories\]/);
|
||||
assert.match(output, /relevant info/);
|
||||
assert.match(output, /Base prompt/);
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext returns base prompt when disabled', async () => {
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-1',
|
||||
memoryService: {},
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'false' }
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext(params);
|
||||
assert.equal(output, 'Base prompt');
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext handles missing query or service', async () => {
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: '',
|
||||
agentContext: null,
|
||||
agent_id: 'agent-1',
|
||||
memoryService: null,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { test, describe, beforeEach } 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,108 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { ingestMemoryWindow } from '../src/services/memory/ingestor.js';
|
||||
|
||||
test('ingestMemoryWindow gathers sources, redacts pii, chunks, embeds, links, and emits telemetry', async () => {
|
||||
const inserted = [];
|
||||
const linked = [];
|
||||
const telemetry = [];
|
||||
const upserted = [];
|
||||
|
||||
const repository = {
|
||||
async insertMemory(memory) {
|
||||
inserted.push(memory);
|
||||
return memory;
|
||||
},
|
||||
async linkEmbedding(memoryId, embedding) {
|
||||
linked.push({ memoryId, embedding });
|
||||
},
|
||||
};
|
||||
|
||||
const runtimeCache = {
|
||||
getRecentRuntimeEvents() {
|
||||
return [
|
||||
{ agent_id: 'agent-1', content: 'runtime event [email protected]', timestamp: 1100, tags: ['runtime'], metadata: {} },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const logSource = {
|
||||
async getEvents() {
|
||||
return [
|
||||
{ agent_id: 'agent-1', content: 'call me at 555-555-1212', timestamp: 1200, tags: ['log'], metadata: {} },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const records = await ingestMemoryWindow(
|
||||
{
|
||||
events: [
|
||||
{ agent_id: 'agent-1', content: 'hello', timestamp: 1000, tags: ['direct'], metadata: { session_id: 's1' } },
|
||||
],
|
||||
runtimeCache,
|
||||
logSource,
|
||||
windowStart: 900,
|
||||
windowEnd: 2000,
|
||||
},
|
||||
{
|
||||
repository,
|
||||
maxChunkChars: 40,
|
||||
embedText: (text) => [text.length],
|
||||
emitTelemetry: (event, payload) => telemetry.push({ event, payload }),
|
||||
embedderAdapter: {
|
||||
async embedText(text) {
|
||||
return [text.length];
|
||||
},
|
||||
async upsertVector(entry) {
|
||||
upserted.push(entry);
|
||||
},
|
||||
async queryVector() {
|
||||
return [];
|
||||
},
|
||||
async deleteVector() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
now: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(records.length, 2);
|
||||
assert.equal(inserted.length, 2);
|
||||
assert.equal(linked.length, 2);
|
||||
assert.equal(telemetry.length, 2);
|
||||
assert.equal(upserted.length, 2);
|
||||
assert.ok(inserted.some((item) => item.content.includes('[redacted:email]')));
|
||||
assert.ok(inserted.some((item) => item.content.includes('[redacted:phone]')));
|
||||
assert.ok(telemetry.every((entry) => entry.event === 'memory:ingested'));
|
||||
assert.ok(linked.every((entry) => typeof entry.embedding.metadata?.context_excerpt === 'string'));
|
||||
assert.ok(linked.every((entry) => entry.embedding.metadata.context_excerpt.length > 0));
|
||||
});
|
||||
|
||||
test('ingestMemoryWindow dedupes overlapping windows by hashed content + agent + timestamp bucket', async () => {
|
||||
const inserted = [];
|
||||
|
||||
const repository = {
|
||||
async insertMemory(memory) {
|
||||
inserted.push(memory);
|
||||
return memory;
|
||||
},
|
||||
};
|
||||
|
||||
const input = {
|
||||
events: [
|
||||
{ agent_id: 'agent-2', content: 'stable content', timestamp: 10_000, tags: ['a'], metadata: {} },
|
||||
{ agent_id: 'agent-2', content: 'stable content two', timestamp: 10_200, tags: ['b'], metadata: {} },
|
||||
],
|
||||
windowStart: 10_000,
|
||||
windowEnd: 10_500,
|
||||
};
|
||||
|
||||
const first = await ingestMemoryWindow(input, { repository, now: 10_500 });
|
||||
const second = await ingestMemoryWindow(input, { repository, now: 10_500 });
|
||||
|
||||
assert.equal(first.length, 1);
|
||||
assert.equal(second.length, 0);
|
||||
assert.equal(inserted.length, 1);
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getRelevantMemories, pinMemory, unpinMemory } from '../src/services/memory/index.js';
|
||||
|
||||
test('pinMemory pins the memory and clears the cache', async () => {
|
||||
const records = [
|
||||
{ id: 'm1', agent_id: 'agent-a', type: 'event', tags: ['ops'], pinned: false, 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-a', repository });
|
||||
assert.equal(listMemoriesCallCount, 1, 'Repository should be queried initially');
|
||||
|
||||
// 2. Verify cache hit
|
||||
await getRelevantMemories({ agent_id: 'agent-a', repository });
|
||||
assert.equal(listMemoriesCallCount, 1, 'Repository should NOT be queried on cache hit');
|
||||
|
||||
// 3. Pin memory
|
||||
const pinned = await pinMemory('m1', { repository });
|
||||
assert.ok(pinned);
|
||||
assert.equal(pinned.pinned, true);
|
||||
assert.equal(records[0].pinned, true);
|
||||
|
||||
// 4. Verify cache miss (cleared)
|
||||
await getRelevantMemories({ agent_id: 'agent-a', repository });
|
||||
assert.equal(listMemoriesCallCount, 2, 'Repository should be queried again after cache clear');
|
||||
});
|
||||
|
||||
test('pinMemory 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-b', repository });
|
||||
assert.equal(listMemoriesCallCount, 1);
|
||||
|
||||
// 2. Pin non-existent memory
|
||||
const result = await pinMemory('non-existent', { repository });
|
||||
assert.equal(result, null);
|
||||
|
||||
// 3. Verify cache cleared
|
||||
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');
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyLifecycleActions, createLifecyclePlan } from '../src/services/memory/pruner.js';
|
||||
|
||||
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 ?? 'default content',
|
||||
summary: overrides.summary ?? 'default summary',
|
||||
tags: overrides.tags ?? [],
|
||||
importance: overrides.importance ?? 0.5,
|
||||
embedding_id: overrides.embedding_id ?? null,
|
||||
created_at: overrides.created_at ?? 1_000,
|
||||
last_seen: overrides.last_seen ?? 1_000,
|
||||
source: overrides.source ?? 'ingest',
|
||||
ttl_days: overrides.ttl_days ?? null,
|
||||
merged_into: overrides.merged_into ?? null,
|
||||
pinned: overrides.pinned ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test('createLifecyclePlan respects delete safety constraints and logs reasons', async () => {
|
||||
const now = 80 * 24 * 60 * 60 * 1000;
|
||||
const memories = [
|
||||
createMemory({
|
||||
id: 'pinned',
|
||||
pinned: true,
|
||||
importance: 0.01,
|
||||
created_at: 0,
|
||||
last_seen: 0,
|
||||
ttl_days: 1,
|
||||
tags: ['ops'],
|
||||
}),
|
||||
createMemory({
|
||||
id: 'borderline',
|
||||
importance: 0.2,
|
||||
created_at: 0,
|
||||
last_seen: now - (30 * 24 * 60 * 60 * 1000),
|
||||
ttl_days: 1,
|
||||
tags: ['ops'],
|
||||
}),
|
||||
createMemory({
|
||||
id: 'deletable',
|
||||
importance: 0.05,
|
||||
created_at: 0,
|
||||
last_seen: 0,
|
||||
ttl_days: 1,
|
||||
tags: ['ops'],
|
||||
}),
|
||||
];
|
||||
|
||||
const plan = await createLifecyclePlan(memories, {
|
||||
now,
|
||||
retentionMs: 30 * 24 * 60 * 60 * 1000,
|
||||
getEmbedding: (memory) => (memory.id === 'borderline' ? [1, 0] : [0, 1]),
|
||||
});
|
||||
|
||||
const byId = new Map(plan.actions.map((entry) => [entry.id, entry]));
|
||||
assert.equal(byId.get('pinned')?.action, 'keep');
|
||||
assert.equal(byId.get('borderline')?.action, 'archive');
|
||||
assert.equal(byId.get('deletable')?.action, 'delete');
|
||||
|
||||
for (const log of plan.policyLogs) {
|
||||
assert.equal(typeof log.reason, 'string');
|
||||
assert.ok(log.reason.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('createLifecyclePlan condenses near-duplicates and archives merged source records', async () => {
|
||||
const now = 100_000;
|
||||
const memories = [
|
||||
createMemory({
|
||||
id: 'a',
|
||||
summary: 'service restarted due to outage',
|
||||
importance: 0.2,
|
||||
created_at: 95_000,
|
||||
last_seen: 95_000,
|
||||
tags: ['incident', 'ops'],
|
||||
}),
|
||||
createMemory({
|
||||
id: 'b',
|
||||
summary: 'outage led to service restart',
|
||||
importance: 0.2,
|
||||
created_at: 95_100,
|
||||
last_seen: 95_100,
|
||||
tags: ['incident'],
|
||||
}),
|
||||
];
|
||||
|
||||
const plan = await createLifecyclePlan(memories, {
|
||||
now,
|
||||
retentionMs: 1_000,
|
||||
duplicateWindowMs: 10_000,
|
||||
similarityThreshold: 0.9,
|
||||
getEmbedding: (memory) => (memory.id === 'a' ? [1, 0] : [0.99, 0.01]),
|
||||
generateSummary: async () => JSON.stringify({
|
||||
summary: 'Service outage and restart incident summary.',
|
||||
importance: 0.4,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(plan.condensedGroups.length, 1);
|
||||
assert.equal(plan.condensedGroups[0].merged.id, 'b');
|
||||
|
||||
const mergedDecision = plan.actions.find((item) => item.id === 'a');
|
||||
assert.equal(mergedDecision?.action, 'archive');
|
||||
assert.equal(mergedDecision?.merged_into, 'b');
|
||||
});
|
||||
|
||||
test('applyLifecycleActions executes keep/archive/delete and merge markers', async () => {
|
||||
const calls = [];
|
||||
const repository = {
|
||||
async markMerged(id, mergedInto) {
|
||||
calls.push(['markMerged', id, mergedInto]);
|
||||
},
|
||||
async keepMemory(id, reason) {
|
||||
calls.push(['keepMemory', id, reason]);
|
||||
},
|
||||
async archiveMemory(id, reason) {
|
||||
calls.push(['archiveMemory', id, reason]);
|
||||
},
|
||||
async deleteMemory(id, reason) {
|
||||
calls.push(['deleteMemory', id, reason]);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await applyLifecycleActions(repository, {
|
||||
actions: [
|
||||
{ id: 'k1', action: 'keep', reason: 'keep reason', merged_into: null },
|
||||
{ id: 'a1', action: 'archive', reason: 'archive reason', merged_into: 'k1' },
|
||||
{ id: 'd1', action: 'delete', reason: 'delete reason', merged_into: null },
|
||||
],
|
||||
policyLogs: [],
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { filterAndRankMemories } from '../src/services/memory/retriever.js';
|
||||
import {
|
||||
formatMemoriesForPrompt,
|
||||
isMemoryRetrievalEnabled,
|
||||
renderPromptWithMemoryContext,
|
||||
} from '../src/services/memory/formatter.js';
|
||||
|
||||
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 ?? 'default content',
|
||||
summary: overrides.summary ?? 'default summary',
|
||||
tags: overrides.tags ?? [],
|
||||
importance: overrides.importance ?? 0.5,
|
||||
embedding_id: overrides.embedding_id ?? null,
|
||||
created_at: overrides.created_at ?? 1000,
|
||||
last_seen: overrides.last_seen ?? 1000,
|
||||
source: overrides.source ?? 'ingest',
|
||||
ttl_days: overrides.ttl_days ?? null,
|
||||
merged_into: overrides.merged_into ?? null,
|
||||
pinned: overrides.pinned ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test('filterAndRankMemories applies metadata filters and composite score', async () => {
|
||||
const now = 10_000;
|
||||
const memories = [
|
||||
createMemory({
|
||||
id: 'm1',
|
||||
content: 'billing incident root cause',
|
||||
summary: 'billing incident',
|
||||
importance: 0.9,
|
||||
tags: ['incident', 'billing'],
|
||||
created_at: 9000,
|
||||
last_seen: 9500,
|
||||
pinned: true,
|
||||
}),
|
||||
createMemory({
|
||||
id: 'm2',
|
||||
content: 'billing note',
|
||||
summary: 'routine billing note',
|
||||
importance: 0.2,
|
||||
tags: ['billing'],
|
||||
created_at: 8000,
|
||||
last_seen: 8000,
|
||||
pinned: false,
|
||||
}),
|
||||
createMemory({
|
||||
id: 'm3',
|
||||
content: 'other agent record',
|
||||
summary: 'not for this agent',
|
||||
agent_id: 'agent-2',
|
||||
tags: ['incident', 'billing'],
|
||||
}),
|
||||
];
|
||||
|
||||
const vectorAdapter = {
|
||||
async embedText() {
|
||||
return [1, 0];
|
||||
},
|
||||
async queryVector() {
|
||||
return [
|
||||
{ id: 'm1', score: 0.95 },
|
||||
{ id: 'm2', score: 0.8 },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const ranked = await filterAndRankMemories(memories, {
|
||||
agent_id: 'agent-1',
|
||||
query: 'billing incident',
|
||||
tags: ['billing'],
|
||||
timeframe: { from: 7000, to: 10_000 },
|
||||
pinnedPreference: 'prefer',
|
||||
k: 5,
|
||||
now,
|
||||
vectorAdapter,
|
||||
});
|
||||
|
||||
assert.deepEqual(ranked.map((item) => item.id), ['m1', 'm2']);
|
||||
|
||||
const pinnedOnly = await filterAndRankMemories(memories, {
|
||||
agent_id: 'agent-1',
|
||||
query: 'billing',
|
||||
pinnedPreference: 'only',
|
||||
k: 5,
|
||||
now,
|
||||
vectorAdapter,
|
||||
});
|
||||
|
||||
assert.deepEqual(pinnedOnly.map((item) => item.id), ['m1']);
|
||||
});
|
||||
|
||||
test('formatMemoriesForPrompt returns prompt blocks and enforces token budget trimming', () => {
|
||||
const memories = [
|
||||
createMemory({
|
||||
id: 'm1',
|
||||
created_at: 1_700_000_000_000,
|
||||
importance: 0.95,
|
||||
summary: 'A'.repeat(200),
|
||||
content: 'B'.repeat(500),
|
||||
tags: ['incident', 'urgent'],
|
||||
}),
|
||||
createMemory({
|
||||
id: 'm2',
|
||||
created_at: 1_700_000_100_000,
|
||||
importance: 0.7,
|
||||
summary: 'second',
|
||||
content: 'short context',
|
||||
tags: ['ops'],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = formatMemoriesForPrompt(memories, {
|
||||
tokenBudget: 70,
|
||||
k: 2,
|
||||
});
|
||||
|
||||
assert.ok(result.blocks.length >= 1);
|
||||
assert.ok(result.blocks.length <= 2);
|
||||
assert.ok(result.usedTokens <= 70);
|
||||
assert.ok(result.text.includes('[m1,'));
|
||||
if (result.blocks.length === 1) {
|
||||
assert.equal(result.truncated, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('isMemoryRetrievalEnabled supports global toggle and agent canary list', () => {
|
||||
assert.equal(isMemoryRetrievalEnabled('agent-a', { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }), true);
|
||||
assert.equal(isMemoryRetrievalEnabled('agent-a', { TORCH_MEMORY_RETRIEVAL_ENABLED: 'false' }), false);
|
||||
assert.equal(
|
||||
isMemoryRetrievalEnabled('agent-a', { TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-a,agent-b' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isMemoryRetrievalEnabled('agent-c', { TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-a,agent-b' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isMemoryRetrievalEnabled('agent-a', {
|
||||
TORCH_MEMORY_ENABLED: 'false',
|
||||
TORCH_MEMORY_RETRIEVAL_ENABLED: 'true',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext injects bounded memory block before base prompt', async () => {
|
||||
const memories = [
|
||||
createMemory({
|
||||
id: 'm-ctx',
|
||||
summary: 'Known issue in deploy pipeline',
|
||||
content: 'Retry build after rotating temporary token',
|
||||
created_at: 1_700_000_000_000,
|
||||
importance: 0.91,
|
||||
tags: ['deploy', 'incident'],
|
||||
}),
|
||||
];
|
||||
|
||||
const calls = [];
|
||||
const usageUpdates = [];
|
||||
const memoryService = {
|
||||
async getRelevantMemories(params) {
|
||||
calls.push(params);
|
||||
return memories;
|
||||
},
|
||||
async updateMemoryUsage(id, lastSeen) {
|
||||
usageUpdates.push({ id, lastSeen });
|
||||
},
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext({
|
||||
basePrompt: 'Base final prompt body',
|
||||
userRequest: 'Please debug deployment failure',
|
||||
agentContext: { branch: 'main', tool: 'torch' },
|
||||
agent_id: 'agent-a',
|
||||
memoryService,
|
||||
retrievalK: 1,
|
||||
tokenBudget: 60,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-a,agent-b' },
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].agent_id, 'agent-a');
|
||||
assert.equal(calls[0].k, 1);
|
||||
assert.match(calls[0].query, /Please debug deployment failure/);
|
||||
assert.match(calls[0].query, /"branch":"main"/);
|
||||
|
||||
const headerIndex = output.indexOf('[CONTEXT — Relevant memories]');
|
||||
const baseIndex = output.indexOf('Base final prompt body');
|
||||
assert.ok(headerIndex >= 0);
|
||||
assert.ok(baseIndex > headerIndex);
|
||||
assert.ok(output.includes('[m-ctx,'));
|
||||
|
||||
assert.equal(usageUpdates.length, 1);
|
||||
assert.equal(usageUpdates[0].id, 'm-ctx');
|
||||
assert.equal(typeof usageUpdates[0].lastSeen, 'number');
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
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
|
||||
// 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();
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { test, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createDbAdvisoryLockProvider,
|
||||
startMemoryMaintenanceScheduler,
|
||||
} from '../src/services/memory/scheduler.js';
|
||||
|
||||
test('createDbAdvisoryLockProvider acquires and releases postgres advisory locks', async () => {
|
||||
const calls = [];
|
||||
const db = {
|
||||
async query(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
if (sql.includes('pg_try_advisory_lock')) {
|
||||
return { rows: [{ locked: true }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const lockProvider = createDbAdvisoryLockProvider(db, { namespace: 'tests' });
|
||||
const result = await lockProvider.withLock('job', async () => 42);
|
||||
|
||||
assert.equal(result.acquired, true);
|
||||
assert.equal(result.result, 42);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].sql, /pg_try_advisory_lock/);
|
||||
assert.match(calls[1].sql, /pg_advisory_unlock/);
|
||||
assert.deepEqual(calls[0].params, calls[1].params);
|
||||
});
|
||||
|
||||
test('startMemoryMaintenanceScheduler runs all jobs and emits metrics including retries', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'] });
|
||||
|
||||
const metrics = [];
|
||||
let ingestAttempts = 0;
|
||||
|
||||
const scheduler = startMemoryMaintenanceScheduler({
|
||||
runImmediately: true,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 1,
|
||||
random: () => 0,
|
||||
emitMetric(name, payload) {
|
||||
metrics.push({ name, payload });
|
||||
},
|
||||
lockProvider: {
|
||||
async withLock(key, task) {
|
||||
if (key.includes('consolidate-observations')) {
|
||||
return { acquired: false, result: null };
|
||||
}
|
||||
return { acquired: true, result: await task() };
|
||||
},
|
||||
},
|
||||
handlers: {
|
||||
async ingestRecentRuntimeEvents() {
|
||||
ingestAttempts += 1;
|
||||
if (ingestAttempts === 1) {
|
||||
throw new Error('transient');
|
||||
}
|
||||
return { itemCount: 3 };
|
||||
},
|
||||
async consolidateObservations() {
|
||||
return { itemCount: 11 };
|
||||
},
|
||||
async pruningCycle() {
|
||||
return { itemCount: 2 };
|
||||
},
|
||||
async deepMergeArchivalMaintenance() {
|
||||
return { itemCount: 1 };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Advance time to allow async jobs and retries to complete.
|
||||
// We tick enough to cover the retryDelayMs (1ms).
|
||||
// We also need to allow the promise microtask queue to drain between ticks
|
||||
// if necessary, but tick() usually handles the timer callbacks.
|
||||
// Since the scheduler logic uses await, we need to ensure those promises resolve.
|
||||
|
||||
// Tick time forward and yield to event loop to allow promise chains to progress.
|
||||
// The job involves multiple async steps (lock, handler, retry wait, lock, handler).
|
||||
// We tick multiple times to ensure the retry timer fires and subsequent microtasks run.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mock.timers.tick(5);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
scheduler.stop();
|
||||
mock.timers.reset();
|
||||
|
||||
const byJob = metrics.reduce((acc, entry) => {
|
||||
const key = entry.payload.job;
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(entry);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
assert.equal(byJob.ingestRecentRuntimeEvents?.length, 1, 'Expected 1 ingest metric');
|
||||
assert.equal(byJob.ingestRecentRuntimeEvents[0].payload.status, 'success');
|
||||
assert.equal(byJob.ingestRecentRuntimeEvents[0].payload.itemCount, 3);
|
||||
assert.equal(byJob.ingestRecentRuntimeEvents[0].payload.retries, 1);
|
||||
|
||||
assert.equal(byJob.consolidateObservations[0].payload.status, 'skipped_lock_unavailable');
|
||||
assert.equal(byJob.pruningCycle[0].payload.status, 'success');
|
||||
assert.equal(byJob.deepMergeArchivalMaintenance[0].payload.status, 'success');
|
||||
});
|
||||
|
||||
|
||||
test('startMemoryMaintenanceScheduler skips jobs when feature flags disable memory', async () => {
|
||||
mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'] });
|
||||
|
||||
const metrics = [];
|
||||
const called = [];
|
||||
|
||||
const scheduler = startMemoryMaintenanceScheduler({
|
||||
runImmediately: true,
|
||||
env: { TORCH_MEMORY_ENABLED: 'false' },
|
||||
emitMetric(name, payload) {
|
||||
metrics.push({ name, payload });
|
||||
},
|
||||
handlers: {
|
||||
async ingestRecentRuntimeEvents() {
|
||||
called.push('ingest');
|
||||
},
|
||||
async consolidateObservations() {
|
||||
called.push('consolidate');
|
||||
},
|
||||
async pruningCycle() {
|
||||
called.push('prune');
|
||||
},
|
||||
async deepMergeArchivalMaintenance() {
|
||||
called.push('merge');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.timers.tick(10);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
scheduler.stop();
|
||||
mock.timers.reset();
|
||||
|
||||
assert.deepEqual(called, []);
|
||||
assert.ok(metrics.some((entry) => entry.payload.status === 'skipped_flag_disabled'));
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ingestEvents } from '../src/services/memory/index.js';
|
||||
import {
|
||||
SCHEMA_VERSION,
|
||||
normalizeMemoryItem,
|
||||
validateMemoryItem,
|
||||
} from '../src/services/memory/schema.js';
|
||||
|
||||
test('normalizeMemoryItem applies durable schema defaults and coercions', () => {
|
||||
const item = normalizeMemoryItem({
|
||||
agent_id: 'agent-1',
|
||||
content: 'hello',
|
||||
summary: 'hello',
|
||||
tags: ['a', 2, ''],
|
||||
importance: 2,
|
||||
ttl_days: 3.8,
|
||||
pinned: 'yes',
|
||||
});
|
||||
|
||||
assert.equal(item.schema_version, SCHEMA_VERSION);
|
||||
assert.equal(item.agent_id, 'agent-1');
|
||||
assert.deepEqual(item.tags, ['a', '2']);
|
||||
assert.equal(item.importance, 1);
|
||||
assert.equal(item.ttl_days, 3);
|
||||
assert.equal(item.pinned, true);
|
||||
assert.equal(item.type, 'event');
|
||||
assert.equal(item.source, 'ingest');
|
||||
assert.ok(item.id);
|
||||
});
|
||||
|
||||
test('validateMemoryItem reports field-level errors', () => {
|
||||
const result = validateMemoryItem({
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id: 'id-1',
|
||||
agent_id: '',
|
||||
session_id: 's-1',
|
||||
type: 'event',
|
||||
content: 'content',
|
||||
summary: 'summary',
|
||||
tags: ['tag'],
|
||||
importance: 0.4,
|
||||
embedding_id: null,
|
||||
created_at: Date.now(),
|
||||
last_seen: Date.now(),
|
||||
source: 'ingest',
|
||||
ttl_days: null,
|
||||
merged_into: null,
|
||||
pinned: false,
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.errors.some((error) => error.field === 'agent_id'));
|
||||
});
|
||||
|
||||
test('ingestEvents rejects invalid writes and logs structured validation details', async () => {
|
||||
const originalError = console.error;
|
||||
const entries = [];
|
||||
console.error = (...args) => {
|
||||
entries.push(args);
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => ingestEvents([{ content: 'missing agent id' }]),
|
||||
/Memory ingest rejected/
|
||||
);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
assert.equal(entries.length, 1);
|
||||
const [tag, payload] = entries[0];
|
||||
assert.equal(tag, 'memory_validation_error');
|
||||
assert.equal(payload.stage, 'ingest');
|
||||
assert.ok(Array.isArray(payload.fields));
|
||||
assert.ok(payload.fields.some((field) => field.field === 'agent_id'));
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
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));
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { loadMemoryPromptTemplates, summarizeEvents } from '../src/services/memory/summarizer.js';
|
||||
|
||||
const SAMPLE_EVENTS = [
|
||||
{ agent_id: 'a', content: 'Deployment completed for service alpha', timestamp: 1, tags: [], metadata: {} },
|
||||
{ agent_id: 'a', content: 'Contact: [email protected]', timestamp: 2, tags: [], metadata: {} },
|
||||
];
|
||||
|
||||
test('loadMemoryPromptTemplates loads all memory prompt templates from disk', () => {
|
||||
const templates = loadMemoryPromptTemplates();
|
||||
assert.ok(templates.summarize.includes('STRICT JSON only'));
|
||||
assert.ok(templates.condense.includes('malformed JSON'));
|
||||
assert.ok(templates.score.includes('importance'));
|
||||
assert.ok(templates.prune.includes('prune'));
|
||||
});
|
||||
|
||||
test('summarizeEvents retries with repair prompt when first response is malformed JSON', async () => {
|
||||
const prompts = [];
|
||||
const result = await summarizeEvents(SAMPLE_EVENTS, {
|
||||
generateSummary(prompt) {
|
||||
prompts.push(prompt);
|
||||
if (prompts.length === 1) {
|
||||
return '{"summary":"partial"';
|
||||
}
|
||||
return '{"summary":"Deployment completed for service alpha [redacted:email]","importance":0.6}';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prompts.length, 2);
|
||||
assert.equal(result.importance, 0.6);
|
||||
assert.equal(result.summary, 'Deployment completed for service alpha [redacted:email]');
|
||||
});
|
||||
|
||||
test('summarizeEvents falls back to deterministic minimal summary with conservative importance when parsing fails twice', async () => {
|
||||
const result = await summarizeEvents(SAMPLE_EVENTS, {
|
||||
maxSummaryLength: 80,
|
||||
async generateSummary() {
|
||||
return 'not json';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.importance, 0.25);
|
||||
assert.match(result.summary, /Deployment completed for service alpha/);
|
||||
assert.match(result.summary, /\[redacted:email\]/);
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const FIXTURE_PATH = path.join(process.cwd(), 'test', 'memory-telemetry-check.mjs');
|
||||
|
||||
const FIXTURE_CONTENT = `
|
||||
import { ingestEvents } from '../src/services/memory/index.js';
|
||||
|
||||
// Mock process.stdout.write to capture output
|
||||
const originalWrite = process.stdout.write;
|
||||
let output = '';
|
||||
process.stdout.write = (chunk, encoding, callback) => {
|
||||
output += chunk.toString();
|
||||
return originalWrite.call(process.stdout, chunk, encoding, callback);
|
||||
};
|
||||
|
||||
// Also mock process.env to ensure ingest is DISABLED to trigger skip telemetry
|
||||
process.env.TORCH_MEMORY_ENABLED = 'true';
|
||||
process.env.TORCH_MEMORY_INGEST_ENABLED = 'off';
|
||||
|
||||
async function run() {
|
||||
console.log('--- Start Test ---');
|
||||
|
||||
// Call ingestEvents which should trigger telemetry
|
||||
await ingestEvents([], { agent_id: 'test-agent' });
|
||||
|
||||
console.log('--- End Test ---');
|
||||
|
||||
if (output.includes('memory_telemetry')) {
|
||||
console.log('\\n[FAIL] Found memory_telemetry in stdout (Pollution detected)');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\\n[PASS] Did not find memory_telemetry in stdout (Clean)');
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
`;
|
||||
|
||||
test.before(() => {
|
||||
fs.writeFileSync(FIXTURE_PATH, FIXTURE_CONTENT);
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
if (fs.existsSync(FIXTURE_PATH)) {
|
||||
fs.unlinkSync(FIXTURE_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to run the script and capture output
|
||||
function runScript(env = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [FIXTURE_PATH], {
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
// The script exits with 0 on pass, 1 on fail
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('telemetry should not log to stdout or stderr by default', async () => {
|
||||
const { code, stdout, stderr } = await runScript();
|
||||
|
||||
assert.equal(code, 0, 'Script should pass');
|
||||
// The pass message contains "memory_telemetry", so we check for the FAIL condition specifically
|
||||
assert.doesNotMatch(stdout, /Pollution detected/, 'stdout should not indicate pollution');
|
||||
assert.match(stdout, /\[PASS\]/, 'stdout should indicate pass');
|
||||
|
||||
// stderr should be empty or minimal (no debug logs)
|
||||
assert.doesNotMatch(stderr, /memory_telemetry/, 'stderr should not contain telemetry payload');
|
||||
});
|
||||
|
||||
test('telemetry should log to stderr when NODE_DEBUG=torch-memory is set', async () => {
|
||||
const { code, stdout, stderr } = await runScript({ NODE_DEBUG: 'torch-memory' });
|
||||
|
||||
assert.equal(code, 0, 'Script should pass (stdout is clean)');
|
||||
assert.doesNotMatch(stdout, /Pollution detected/, 'stdout should not indicate pollution');
|
||||
|
||||
// stderr should contain the debug log
|
||||
// util.debuglog format: "TORCH-MEMORY <pid>: <msg>"
|
||||
assert.match(stderr, /TORCH-MEMORY/, 'stderr should contain debug tag');
|
||||
assert.match(stderr, /memory_telemetry/, 'stderr should contain telemetry event name');
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { normalizeMemoryItem, validateMemoryItem } from '../src/services/memory/schema.js';
|
||||
import { summarizeEvents } from '../src/services/memory/summarizer.js';
|
||||
import { ingestMemoryWindow } from '../src/services/memory/ingestor.js';
|
||||
import { filterAndRankMemories } from '../src/services/memory/retriever.js';
|
||||
import { createLifecyclePlan } from '../src/services/memory/pruner.js';
|
||||
import { startMemoryMaintenanceScheduler } from '../src/services/memory/scheduler.js';
|
||||
import {
|
||||
BASE_MEMORY_RECORD,
|
||||
EXPECTED_SUMMARY,
|
||||
EXPECTED_TAGS,
|
||||
FIXED_NOW,
|
||||
SYNTHETIC_EVENTS,
|
||||
} from './fixtures/memory-fixtures.js';
|
||||
|
||||
/** Drain the async job queue without wall-clock dependency. */
|
||||
const flushAsync = async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
const withMockedNow = async (now, run) => {
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
};
|
||||
|
||||
test('schema validation covers required fields, coercion, and rejection paths', () => {
|
||||
const normalized = normalizeMemoryItem({
|
||||
agent_id: 'agent-alpha',
|
||||
content: 'synthetic content',
|
||||
summary: 'synthetic summary',
|
||||
tags: ['incident', 7, ''],
|
||||
importance: 8,
|
||||
ttl_days: 4.9,
|
||||
pinned: 'true',
|
||||
});
|
||||
|
||||
assert.equal(normalized.agent_id, 'agent-alpha');
|
||||
assert.deepEqual(normalized.tags, ['incident', '7']);
|
||||
assert.equal(normalized.importance, 1);
|
||||
assert.equal(normalized.ttl_days, 4);
|
||||
assert.equal(normalized.pinned, true);
|
||||
|
||||
const validation = validateMemoryItem({
|
||||
...BASE_MEMORY_RECORD,
|
||||
agent_id: '',
|
||||
importance: 2,
|
||||
ttl_days: -1,
|
||||
});
|
||||
|
||||
assert.equal(validation.valid, false);
|
||||
assert.ok(validation.errors.some((error) => error.field === 'agent_id'));
|
||||
assert.ok(validation.errors.some((error) => error.field === 'importance'));
|
||||
assert.ok(validation.errors.some((error) => error.field === 'ttl_days'));
|
||||
});
|
||||
|
||||
test('summarizer parser accepts valid json and recovers after malformed json response', async () => {
|
||||
let calls = 0;
|
||||
const summary = await summarizeEvents(SYNTHETIC_EVENTS, {
|
||||
generateSummary() {
|
||||
calls += 1;
|
||||
if (calls === 1) return '{"summary":"broken"';
|
||||
return JSON.stringify({
|
||||
summary: EXPECTED_SUMMARY,
|
||||
importance: 0.78,
|
||||
});
|
||||
},
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(summary.summary, EXPECTED_SUMMARY);
|
||||
assert.equal(summary.importance, 0.78);
|
||||
});
|
||||
|
||||
test('ingest integration persists memory records and links embeddings from event batches', async () => {
|
||||
const inserted = [];
|
||||
const linked = [];
|
||||
|
||||
const repository = {
|
||||
async insertMemory(memory) {
|
||||
inserted.push(memory);
|
||||
return memory;
|
||||
},
|
||||
async linkEmbedding(memoryId, embedding) {
|
||||
linked.push({ memoryId, embedding });
|
||||
},
|
||||
};
|
||||
|
||||
const records = await ingestMemoryWindow(
|
||||
{
|
||||
events: SYNTHETIC_EVENTS,
|
||||
windowStart: FIXED_NOW - 10_000,
|
||||
windowEnd: FIXED_NOW,
|
||||
},
|
||||
{
|
||||
repository,
|
||||
now: FIXED_NOW,
|
||||
maxChunkChars: 1_000,
|
||||
embedText: () => [0.11, 0.89],
|
||||
embedderAdapter: {
|
||||
async embedText() {
|
||||
return [0.11, 0.89];
|
||||
},
|
||||
async upsertVector() {},
|
||||
},
|
||||
generateSummary: async () => JSON.stringify({
|
||||
summary: EXPECTED_SUMMARY,
|
||||
importance: 0.82,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(inserted.length, 1);
|
||||
assert.equal(linked.length, 1);
|
||||
assert.equal(inserted[0].summary, EXPECTED_SUMMARY);
|
||||
assert.deepEqual(inserted[0].tags, EXPECTED_TAGS);
|
||||
assert.equal(linked[0].memoryId, inserted[0].id);
|
||||
});
|
||||
|
||||
test('retrieval ranking blends semantic, importance, and recency signals', async () => {
|
||||
const records = [
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'semantic-high',
|
||||
importance: 0.2,
|
||||
last_seen: FIXED_NOW - 200_000,
|
||||
},
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'importance-high',
|
||||
summary: 'Minor semantic match',
|
||||
importance: 0.95,
|
||||
last_seen: FIXED_NOW - 90_000_000,
|
||||
},
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'recent-balanced',
|
||||
summary: 'Recent task update',
|
||||
importance: 0.7,
|
||||
last_seen: FIXED_NOW - 10_000,
|
||||
},
|
||||
];
|
||||
|
||||
const ranked = await filterAndRankMemories(records, {
|
||||
agent_id: 'agent-alpha',
|
||||
query: 'incident resolved',
|
||||
k: 3,
|
||||
now: FIXED_NOW,
|
||||
vectorAdapter: {
|
||||
async embedText() {
|
||||
return [1, 0];
|
||||
},
|
||||
async queryVector() {
|
||||
return [
|
||||
{ id: 'semantic-high', score: 0.99 },
|
||||
{ id: 'recent-balanced', score: 0.52 },
|
||||
{ id: 'importance-high', score: 0.15 },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(ranked.map((item) => item.id), ['semantic-high', 'recent-balanced', 'importance-high']);
|
||||
});
|
||||
|
||||
test('pruner policy protects pins, merges duplicates, and picks archive/delete outcomes', async () => {
|
||||
const memories = [
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'pinned-memory',
|
||||
pinned: true,
|
||||
importance: 0.01,
|
||||
ttl_days: 1,
|
||||
created_at: FIXED_NOW - (120 * 24 * 60 * 60 * 1000),
|
||||
last_seen: FIXED_NOW - (120 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'merge-a',
|
||||
summary: 'service restart after outage',
|
||||
importance: 0.2,
|
||||
created_at: FIXED_NOW - 10_000,
|
||||
last_seen: FIXED_NOW - 9_000,
|
||||
},
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'merge-b',
|
||||
summary: 'outage triggered service restart',
|
||||
importance: 0.2,
|
||||
created_at: FIXED_NOW - 9_500,
|
||||
last_seen: FIXED_NOW - 8_500,
|
||||
tags: ['incident', 'ops'],
|
||||
},
|
||||
{
|
||||
...BASE_MEMORY_RECORD,
|
||||
id: 'delete-me',
|
||||
importance: 0.01,
|
||||
ttl_days: 1,
|
||||
created_at: FIXED_NOW - (100 * 24 * 60 * 60 * 1000),
|
||||
last_seen: FIXED_NOW - (100 * 24 * 60 * 60 * 1000),
|
||||
pinned: false,
|
||||
},
|
||||
];
|
||||
|
||||
const plan = await createLifecyclePlan(memories, {
|
||||
now: FIXED_NOW,
|
||||
retentionMs: 30 * 24 * 60 * 60 * 1000,
|
||||
similarityThreshold: 0.9,
|
||||
duplicateWindowMs: 60_000,
|
||||
getEmbedding(memory) {
|
||||
if (memory.id === 'merge-a') return [1, 0];
|
||||
if (memory.id === 'merge-b') return [0.99, 0.01];
|
||||
return [0, 1];
|
||||
},
|
||||
generateSummary: async () => JSON.stringify({
|
||||
summary: 'Merged outage restart summary',
|
||||
importance: 0.4,
|
||||
}),
|
||||
});
|
||||
|
||||
const actionById = new Map(plan.actions.map((action) => [action.id, action.action]));
|
||||
assert.equal(actionById.get('pinned-memory'), 'keep');
|
||||
assert.equal(actionById.get('merge-a'), 'archive');
|
||||
assert.equal(actionById.get('merge-b'), 'keep');
|
||||
assert.equal(actionById.get('delete-me'), 'delete');
|
||||
});
|
||||
|
||||
test('scheduler smoke test handles mocked clock and lock contention', async () => {
|
||||
const metrics = [];
|
||||
|
||||
await withMockedNow(FIXED_NOW, async () => {
|
||||
const scheduler = startMemoryMaintenanceScheduler({
|
||||
runImmediately: true,
|
||||
maxRetries: 0,
|
||||
random: () => 0,
|
||||
emitMetric(name, payload) {
|
||||
metrics.push({ name, payload });
|
||||
},
|
||||
lockProvider: {
|
||||
async withLock(lockKey, task) {
|
||||
if (lockKey.includes('consolidate-observations')) {
|
||||
return { acquired: false, result: null };
|
||||
}
|
||||
return { acquired: true, result: await task() };
|
||||
},
|
||||
},
|
||||
handlers: {
|
||||
async ingestRecentRuntimeEvents() {
|
||||
return { itemCount: 2 };
|
||||
},
|
||||
async consolidateObservations() {
|
||||
return { itemCount: 0 };
|
||||
},
|
||||
async pruningCycle() {
|
||||
return { itemCount: 1 };
|
||||
},
|
||||
async deepMergeArchivalMaintenance() {
|
||||
return { itemCount: 1 };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushAsync();
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
const statuses = new Map(metrics.map((entry) => [entry.payload.job, entry.payload.status]));
|
||||
assert.equal(statuses.get('ingestRecentRuntimeEvents'), 'success');
|
||||
assert.equal(statuses.get('consolidateObservations'), 'skipped_lock_unavailable');
|
||||
assert.equal(statuses.get('pruningCycle'), 'success');
|
||||
assert.equal(statuses.get('deepMergeArchivalMaintenance'), 'success');
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { spawnSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
describe('CLI Smoke Test', () => {
|
||||
it('should print usage when no args provided', () => {
|
||||
const result = spawnSync('node', ['bin/torch-lock.mjs']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr.toString(), /Usage:/);
|
||||
});
|
||||
|
||||
it('should fail when checking without cadence', () => {
|
||||
const result = spawnSync('node', ['bin/torch-lock.mjs', 'check']);
|
||||
assert.strictEqual(result.status, 1);
|
||||
assert.match(result.stderr.toString(), /--cadence <daily|weekly> is required/);
|
||||
});
|
||||
|
||||
it('should include paused agents in check output', async () => {
|
||||
// Start a mock relay
|
||||
const wss = new WebSocketServer({ port: 0 });
|
||||
const port = await new Promise(resolve => wss.on('listening', () => resolve(wss.address().port)));
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (message) => {
|
||||
const [type, subId] = JSON.parse(message);
|
||||
if (type === 'REQ') {
|
||||
ws.send(JSON.stringify(['EOSE', subId]));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const configPath = 'test-torch-config-paused.json';
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
nostrLock: {
|
||||
relays: [`ws://127.0.0.1:${port}`]
|
||||
},
|
||||
scheduler: { paused: { daily: ['paused-agent'] } }
|
||||
}));
|
||||
|
||||
try {
|
||||
const child = spawn('node', ['bin/torch-lock.mjs', 'check', '--cadence', 'daily', '--json', '--quiet'], {
|
||||
env: { ...process.env, TORCH_CONFIG_PATH: configPath }
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', data => stdout += data);
|
||||
|
||||
const code = await new Promise(resolve => child.on('close', resolve));
|
||||
|
||||
assert.strictEqual(code, 0);
|
||||
const output = JSON.parse(stdout);
|
||||
assert.deepStrictEqual(output.paused, ['paused-agent']);
|
||||
assert.ok(output.excluded.includes('paused-agent'));
|
||||
} finally {
|
||||
wss.close();
|
||||
if (fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdInit } from '../src/ops.mjs';
|
||||
|
||||
test('ops.mjs: cmdInit creates .gitignore with node_modules', async (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), 'test-gitignore-'));
|
||||
|
||||
// Clean up afterwards
|
||||
t.after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const mockAnswers = {
|
||||
installDir: 'torch',
|
||||
namespace: 'test-ns',
|
||||
hashtag: 'test-hash',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
};
|
||||
|
||||
// Run init
|
||||
// We silence console logs to keep test output clean, if possible, but it's fine.
|
||||
await cmdInit(false, tmpDir, mockAnswers);
|
||||
|
||||
const gitIgnorePath = path.join(tmpDir, 'torch', '.gitignore');
|
||||
|
||||
if (fs.existsSync(gitIgnorePath)) {
|
||||
const content = fs.readFileSync(gitIgnorePath, 'utf8');
|
||||
assert.match(content, /node_modules/, '.gitignore should contain node_modules');
|
||||
} else {
|
||||
assert.fail('.gitignore was not created');
|
||||
}
|
||||
});
|
||||
|
||||
test('ops.mjs: cmdInit appends to existing .gitignore', async (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), 'test-gitignore-existing-'));
|
||||
|
||||
// Clean up afterwards
|
||||
t.after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const torchDir = path.join(tmpDir, 'torch');
|
||||
fs.mkdirSync(torchDir, { recursive: true });
|
||||
|
||||
// Create existing .gitignore without node_modules
|
||||
fs.writeFileSync(path.join(torchDir, '.gitignore'), 'existing-pattern\n', 'utf8');
|
||||
|
||||
const mockAnswers = {
|
||||
installDir: 'torch',
|
||||
namespace: 'test-ns',
|
||||
hashtag: 'test-hash',
|
||||
relays: ['wss://relay.damus.io'],
|
||||
};
|
||||
|
||||
// Run init (force=true because directory exists)
|
||||
await cmdInit(true, tmpDir, mockAnswers);
|
||||
|
||||
const gitIgnorePath = path.join(torchDir, '.gitignore');
|
||||
const content = fs.readFileSync(gitIgnorePath, 'utf8');
|
||||
|
||||
assert.match(content, /existing-pattern/, 'Should preserve existing content');
|
||||
assert.match(content, /node_modules/, 'Should append node_modules');
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
function runInitWithSingleNewline(cwd) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [path.resolve('bin/torch-lock.mjs'), 'init', '--force'], {
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.stdin.write('\n');
|
||||
child.stdin.end();
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('init completes in non-interactive mode when stdin closes early', async () => {
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'torch-init-noninteractive-'));
|
||||
await fs.promises.writeFile(path.join(tmpDir, 'package.json'), JSON.stringify({ name: 'tmp-host', version: '1.0.0' }), 'utf8');
|
||||
|
||||
const result = await runInitWithSingleNewline(tmpDir);
|
||||
assert.equal(result.code, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, 'torch')), 'torch directory should be created');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, 'torch-config.json')), 'root torch-config.json should be created');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, 'torch', 'src', 'prompts', 'scheduler-flow.md')), 'scheduler flow should be installed');
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { cmdInit, cmdUpdate } from '../src/ops.mjs';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const MOCK_CONFIG = {
|
||||
installDir: 'torch',
|
||||
namespace: 'test-namespace',
|
||||
hashtag: 'test-hashtag',
|
||||
relays: ['wss://relay.damus.io']
|
||||
};
|
||||
|
||||
const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-ops-'));
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tempBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('cmdInit creates directory structure and files', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project1');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
const torchDir = path.join(projectRoot, 'torch');
|
||||
assert.ok(fs.existsSync(torchDir), 'torch dir created');
|
||||
assert.ok(fs.existsSync(path.join(torchDir, 'prompts', 'daily')), 'daily prompts created');
|
||||
assert.ok(fs.existsSync(path.join(torchDir, 'roster.json')), 'roster created');
|
||||
assert.ok(fs.existsSync(path.join(torchDir, 'META_PROMPTS.md')), 'META_PROMPTS.md created');
|
||||
assert.ok(fs.existsSync(path.join(torchDir, 'eslint.config.mjs')), 'eslint config copied');
|
||||
|
||||
// Check content transformation
|
||||
const metaPrompts = fs.readFileSync(path.join(torchDir, 'META_PROMPTS.md'), 'utf8');
|
||||
assert.ok(metaPrompts.includes('torch/prompts/daily/'), 'paths replaced in META_PROMPTS');
|
||||
|
||||
// Check Dashboard file
|
||||
const dashboardPath = path.join(torchDir, 'TORCH_DASHBOARD.md');
|
||||
assert.ok(fs.existsSync(dashboardPath), 'TORCH_DASHBOARD.md created');
|
||||
const dashboardContent = fs.readFileSync(dashboardPath, 'utf8');
|
||||
assert.ok(dashboardContent.includes('test-hashtag'), 'Dashboard content contains hashtag');
|
||||
assert.ok(dashboardContent.includes('test-namespace'), 'Dashboard content contains namespace');
|
||||
assert.ok(dashboardContent.includes('https://www.torchagent.ai/dashboard/'), 'Dashboard content contains link');
|
||||
});
|
||||
|
||||
test('cmdInit fails if directory exists without force', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project2');
|
||||
const torchDir = path.join(projectRoot, 'torch');
|
||||
fs.mkdirSync(torchDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(torchDir, 'dummy.txt'), 'content');
|
||||
|
||||
await assert.rejects(
|
||||
async () => await cmdInit(false, projectRoot, MOCK_CONFIG),
|
||||
/already exists/,
|
||||
'Should fail if torch dir exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('cmdUpdate preserves modified prompt', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project3');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
const dailyDir = path.join(projectRoot, 'torch', 'prompts', 'daily');
|
||||
const files = fs.readdirSync(dailyDir);
|
||||
assert.ok(files.length > 0, 'Should have daily prompts');
|
||||
|
||||
const targetFile = path.join(dailyDir, files[0]);
|
||||
fs.writeFileSync(targetFile, 'MODIFIED CONTENT', 'utf8');
|
||||
|
||||
await cmdUpdate(false, projectRoot);
|
||||
|
||||
const content = fs.readFileSync(targetFile, 'utf8');
|
||||
assert.strictEqual(content, 'MODIFIED CONTENT', 'Prompt should be preserved');
|
||||
});
|
||||
|
||||
test('cmdUpdate overwrites prompt with force', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project4');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
const dailyDir = path.join(projectRoot, 'torch', 'prompts', 'daily');
|
||||
const files = fs.readdirSync(dailyDir);
|
||||
const targetFile = path.join(dailyDir, files[0]);
|
||||
|
||||
fs.writeFileSync(targetFile, 'MODIFIED CONTENT', 'utf8');
|
||||
|
||||
await cmdUpdate(true, projectRoot); // Force update
|
||||
|
||||
const content = fs.readFileSync(targetFile, 'utf8');
|
||||
assert.notStrictEqual(content, 'MODIFIED CONTENT', 'Prompt should be overwritten');
|
||||
});
|
||||
|
||||
test('cmdUpdate creates backup', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project5');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
await cmdUpdate(false, projectRoot);
|
||||
|
||||
const torchDir = path.join(projectRoot, 'torch');
|
||||
const backupRoot = path.join(torchDir, '_backups');
|
||||
assert.ok(fs.existsSync(backupRoot), '_backups directory created');
|
||||
|
||||
const entries = fs.readdirSync(backupRoot);
|
||||
assert.ok(entries.length > 0, 'Should have at least one backup');
|
||||
const backupName = entries[0];
|
||||
assert.match(backupName, /^backup_/, 'Backup directory name starts with backup_');
|
||||
|
||||
const backupContent = fs.readdirSync(path.join(backupRoot, backupName));
|
||||
assert.ok(backupContent.includes('roster.json'), 'Backup contains roster.json');
|
||||
});
|
||||
|
||||
test('cmdUpdate succeeds when running from package root install (src and dest identical)', async () => {
|
||||
const fixtureRoot = path.join(tempBase, 'project_self_root');
|
||||
fs.mkdirSync(fixtureRoot, { recursive: true });
|
||||
|
||||
const dirsToCopy = ['src', 'bin', 'dashboard', 'landing', 'assets', 'scripts'];
|
||||
const filesToCopy = ['package.json', 'build.mjs', 'README.md', 'torch-config.example.json', 'TORCH.md', 'eslint.config.mjs'];
|
||||
|
||||
for (const dir of dirsToCopy) {
|
||||
fs.cpSync(path.join(process.cwd(), dir), path.join(fixtureRoot, dir), { recursive: true });
|
||||
}
|
||||
for (const file of filesToCopy) {
|
||||
fs.copyFileSync(path.join(process.cwd(), file), path.join(fixtureRoot, file));
|
||||
}
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(fixtureRoot, 'src', 'ops.mjs'));
|
||||
const { cmdUpdate: fixtureCmdUpdate } = await import(moduleUrl.href);
|
||||
|
||||
await assert.doesNotReject(async () => {
|
||||
await fixtureCmdUpdate(true, fixtureRoot);
|
||||
}, 'update should not fail when package root is install target');
|
||||
|
||||
const backupRoot = path.join(fixtureRoot, '_backups');
|
||||
assert.ok(fs.existsSync(backupRoot), '_backups directory created for package-root update');
|
||||
assert.ok(fs.readdirSync(backupRoot).length > 0, 'backup entry created for package-root update');
|
||||
});
|
||||
|
||||
test('torch-lock check respects local roster', async () => {
|
||||
const wss = new WebSocketServer({ port: 0 });
|
||||
const port = wss.address().port;
|
||||
const mockRelayUrl = `ws://127.0.0.1:${port}`;
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if (data[0] === 'REQ') {
|
||||
const subId = data[1];
|
||||
ws.send(JSON.stringify(['EOSE', subId]));
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const projectRoot = path.join(tempBase, 'project_roster');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
// Use local mock relay
|
||||
const localConfig = { ...MOCK_CONFIG, relays: [mockRelayUrl] };
|
||||
|
||||
// Init
|
||||
await cmdInit(false, projectRoot, localConfig);
|
||||
|
||||
// Modify roster
|
||||
const rosterPath = path.join(projectRoot, 'torch', 'roster.json');
|
||||
const roster = JSON.parse(fs.readFileSync(rosterPath, 'utf8'));
|
||||
roster.daily.push('custom-agent-007');
|
||||
fs.writeFileSync(rosterPath, JSON.stringify(roster, null, 2));
|
||||
|
||||
// Run CLI
|
||||
const binPath = path.resolve(process.cwd(), 'bin/torch-lock.mjs');
|
||||
|
||||
const { stdout } = await execAsync(`${process.execPath} ${binPath} check --cadence daily`, {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NOSTR_LOCK_RELAYS: undefined, NOSTR_LOCK_RELAY_FALLBACKS: '' }
|
||||
});
|
||||
|
||||
const result = JSON.parse(stdout);
|
||||
assert.ok(result.available.includes('custom-agent-007'), 'Custom agent found in roster');
|
||||
} finally {
|
||||
wss.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cmdInit creates torch-config.json with random namespace', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project_config_init');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
const configPath = path.join(projectRoot, 'torch-config.json');
|
||||
assert.ok(fs.existsSync(configPath), 'torch-config.json created');
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
assert.strictEqual(config.nostrLock.namespace, 'test-namespace', 'Namespace should match mock');
|
||||
assert.strictEqual(config.dashboard.hashtag, 'test-hashtag', 'Hashtag should match mock');
|
||||
assert.strictEqual(
|
||||
config.scheduler.handoffCommandByCadence.daily,
|
||||
'node torch/scripts/agent/run-selected-prompt.mjs',
|
||||
'daily handoff command should be configured for host installs',
|
||||
);
|
||||
assert.strictEqual(
|
||||
config.scheduler.handoffCommandByCadence.weekly,
|
||||
'node torch/scripts/agent/run-selected-prompt.mjs',
|
||||
'weekly handoff command should be configured for host installs',
|
||||
);
|
||||
});
|
||||
|
||||
test('cmdInit upserts TORCH memory integration into existing AGENTS.md and CLAUDE.md', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project_memory_hook_init');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
||||
const claudePath = path.join(projectRoot, 'CLAUDE.md');
|
||||
fs.writeFileSync(agentsPath, '# Team Policy\n\nKeep tests strict.\n', 'utf8');
|
||||
fs.writeFileSync(claudePath, '# Local Guidance\n\nFollow repo conventions.\n', 'utf8');
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
|
||||
const agentsContent = fs.readFileSync(agentsPath, 'utf8');
|
||||
const claudeContent = fs.readFileSync(claudePath, 'utf8');
|
||||
assert.ok(agentsContent.includes('## TORCH Memory Integration'), 'AGENTS.md should include memory heading');
|
||||
assert.ok(claudeContent.includes('## TORCH Memory Integration'), 'CLAUDE.md should include memory heading');
|
||||
assert.ok(agentsContent.includes('Keep tests strict.'), 'AGENTS.md existing content should be preserved');
|
||||
assert.ok(claudeContent.includes('Follow repo conventions.'), 'CLAUDE.md existing content should be preserved');
|
||||
});
|
||||
|
||||
test('cmdUpdate keeps TORCH memory integration idempotent', async () => {
|
||||
const projectRoot = path.join(tempBase, 'project_memory_hook_update');
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
|
||||
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
||||
fs.writeFileSync(agentsPath, '# Agent Rules\n', 'utf8');
|
||||
|
||||
await cmdInit(false, projectRoot, MOCK_CONFIG);
|
||||
await cmdUpdate(false, projectRoot);
|
||||
|
||||
const agentsContent = fs.readFileSync(agentsPath, 'utf8');
|
||||
const headingMatches = agentsContent.match(/## TORCH Memory Integration/g) ?? [];
|
||||
assert.strictEqual(headingMatches.length, 1, 'Memory integration block should not be duplicated');
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Prepare logging directory
|
||||
const logDir = 'test_logs';
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir);
|
||||
}
|
||||
const logFile = path.join(logDir, 'playwright_console.log');
|
||||
// Clear previous log
|
||||
fs.writeFileSync(logFile, '');
|
||||
|
||||
test.use({
|
||||
extraHTTPHeaders: {
|
||||
// base64('admin:password') = 'YWRtaW46cGFzc3dvcmQ='
|
||||
'Authorization': 'Basic YWRtaW46cGFzc3dvcmQ='
|
||||
}
|
||||
});
|
||||
|
||||
test.describe.serial('TORCH Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on('console', msg => {
|
||||
const logLine = `[${msg.type()}] ${msg.text()}\n`;
|
||||
fs.appendFileSync(logFile, logLine);
|
||||
// Also log to stdout so it appears in the runner output
|
||||
// console.log(`[Browser Console] ${msg.text()}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should load the dashboard and verify title', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Check for title "TORCH Dashboard" or similar. Checking regex /TORCH/ is safer.
|
||||
await expect(page).toHaveTitle(/TORCH/);
|
||||
|
||||
// Check for some basic element presence to ensure rendering happened
|
||||
// Assuming there is a header or main content
|
||||
// We can inspect dashboard/index.html to be sure
|
||||
// But generic check for body visibility is a start
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
// Verify console log capture
|
||||
await page.evaluate(() => console.log('Test log from browser'));
|
||||
});
|
||||
|
||||
test('should fail authentication with wrong credentials', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
extraHTTPHeaders: {
|
||||
'Authorization': 'Basic YWRtaW46d3Jvbmc=' // admin:wrong
|
||||
}
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const response = await page.goto('/');
|
||||
expect(response.status()).toBe(401);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('should serve public config correctly', async ({ request }) => {
|
||||
const response = await request.get('/torch-config.json');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const config = await response.json();
|
||||
expect(config.nostrLock.namespace).toBe('test-torch');
|
||||
// Security check: Auth should be stripped
|
||||
expect(config.dashboard.auth).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should shutdown gracefully to save coverage', async ({ request }) => {
|
||||
// Attempt to shutdown. It might fail if already down (unlikely in serial).
|
||||
try {
|
||||
// We don't expect a response because the server dies
|
||||
await request.get('/shutdown', { timeout: 1000 }).catch(() => {});
|
||||
} catch (_) {
|
||||
// Ignore
|
||||
}
|
||||
// Wait a bit for file write
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"nostrLock": {
|
||||
"namespace": "test-torch",
|
||||
"relays": ["wss://relay.damus.io"],
|
||||
"relayFallbacks": [],
|
||||
"ttlSeconds": 60,
|
||||
"queryTimeoutMs": 5000,
|
||||
"publishTimeoutMs": 5000,
|
||||
"minSuccessfulRelayPublishes": 1,
|
||||
"minActiveRelayPool": 1
|
||||
},
|
||||
"dashboard": {
|
||||
"auth": "admin:password",
|
||||
"defaultCadenceView": "daily",
|
||||
"defaultStatusView": "active",
|
||||
"relays": ["wss://relay.damus.io"],
|
||||
"namespace": "test-torch",
|
||||
"hashtag": "test-torch-lock"
|
||||
},
|
||||
"scheduler": {
|
||||
"firstPromptByCadence": {
|
||||
"daily": "src/prompts/daily/agent.md",
|
||||
"weekly": "src/prompts/weekly/agent.md"
|
||||
},
|
||||
"paused": {
|
||||
"daily": [],
|
||||
"weekly": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import net from 'node:net';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import { publishLock, _resetRelayHealthState } from '../src/lock-ops.mjs';
|
||||
|
||||
async function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : null;
|
||||
server.close((closeErr) => {
|
||||
if (closeErr) {
|
||||
reject(closeErr);
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function createRelaySimulatorFixture(modes) {
|
||||
if (modes.length !== 3) {
|
||||
throw new Error(`Expected exactly three relays, got ${modes.length}`);
|
||||
}
|
||||
|
||||
const servers = [];
|
||||
const relays = [];
|
||||
|
||||
for (const mode of modes) {
|
||||
const port = await getFreePort();
|
||||
const relayUrl = `ws://127.0.0.1:${port}`;
|
||||
|
||||
if (mode.type === 'connection-fail') {
|
||||
relays.push(relayUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ host: '127.0.0.1', port });
|
||||
wss.on('connection', (socket) => {
|
||||
socket.on('message', (rawData) => {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(rawData.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload) || payload[0] !== 'EVENT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = payload[1] || {};
|
||||
if (mode.type === 'success') {
|
||||
const delayMs = mode.delayMs ?? 0;
|
||||
setTimeout(() => {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify(['OK', event.id, true, 'accepted']));
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
servers.push(wss);
|
||||
relays.push(relayUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
relays,
|
||||
async stop() {
|
||||
await Promise.all(servers.map((server) => new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
})));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTelemetry(lines) {
|
||||
return lines.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function makeEvent(id) {
|
||||
return {
|
||||
id,
|
||||
kind: 1,
|
||||
pubkey: 'f'.repeat(64),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: 'relay-test',
|
||||
sig: 'e'.repeat(128),
|
||||
};
|
||||
}
|
||||
|
||||
describe('relay fanout quorum integration', { concurrency: false }, () => {
|
||||
let fixture = null;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetRelayHealthState();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('passes with required=1 when one relay succeeds and two fail', async () => {
|
||||
fixture = await createRelaySimulatorFixture([
|
||||
{ type: 'success' },
|
||||
{ type: 'timeout' },
|
||||
{ type: 'timeout' },
|
||||
]);
|
||||
|
||||
const telemetryLogs = [];
|
||||
const result = await publishLock(fixture.relays, makeEvent('evt-required1-pass'), {
|
||||
getPublishTimeoutMsFn: () => 80,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.id, 'evt-required1-pass');
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const quorumSummary = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(quorumSummary);
|
||||
assert.strictEqual(quorumSummary.successCount, 1);
|
||||
assert.strictEqual(quorumSummary.requiredSuccesses, 1);
|
||||
assert.strictEqual(quorumSummary.relayAttemptedCount, 3);
|
||||
});
|
||||
|
||||
it('fails with quorum error for required=1 when all relays fail', async () => {
|
||||
fixture = await createRelaySimulatorFixture([
|
||||
{ type: 'timeout' },
|
||||
{ type: 'timeout' },
|
||||
{ type: 'timeout' },
|
||||
]);
|
||||
|
||||
const telemetryLogs = [];
|
||||
await assert.rejects(
|
||||
() => publishLock(fixture.relays, makeEvent('evt-required1-all-fail'), {
|
||||
getPublishTimeoutMsFn: () => 80,
|
||||
getMinSuccessfulRelayPublishesFn: () => 1,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_quorum_failure/,
|
||||
);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const relayOutcomes = telemetry.filter((entry) => entry.event === 'lock_publish_failure');
|
||||
assert.strictEqual(relayOutcomes.length, 3);
|
||||
assert.ok(relayOutcomes.every((entry) => fixture.relays.includes(entry.relayUrl)));
|
||||
|
||||
const quorumSummary = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(quorumSummary);
|
||||
assert.strictEqual(quorumSummary.successCount, 0);
|
||||
assert.strictEqual(quorumSummary.requiredSuccesses, 1);
|
||||
assert.strictEqual(quorumSummary.relayAttemptedCount, 3);
|
||||
});
|
||||
|
||||
it('fails with required=2 when only one relay succeeds', async () => {
|
||||
fixture = await createRelaySimulatorFixture([
|
||||
{ type: 'success' },
|
||||
{ type: 'timeout' },
|
||||
{ type: 'timeout' },
|
||||
]);
|
||||
|
||||
const telemetryLogs = [];
|
||||
await assert.rejects(
|
||||
() => publishLock(fixture.relays, makeEvent('evt-required2-fail'), {
|
||||
getPublishTimeoutMsFn: () => 80,
|
||||
getMinSuccessfulRelayPublishesFn: () => 2,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
}),
|
||||
/error_category=relay_publish_quorum_failure/,
|
||||
);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const relayOutcomes = telemetry.filter((entry) => entry.event === 'lock_publish_failure');
|
||||
assert.strictEqual(relayOutcomes.length, 2);
|
||||
|
||||
const quorumSummary = telemetry.find((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
assert.ok(quorumSummary);
|
||||
assert.strictEqual(quorumSummary.successCount, 1);
|
||||
assert.strictEqual(quorumSummary.requiredSuccesses, 2);
|
||||
assert.strictEqual(quorumSummary.relayAttemptedCount, 3);
|
||||
});
|
||||
|
||||
it('passes when delayed successes reach quorum before timeout', async () => {
|
||||
fixture = await createRelaySimulatorFixture([
|
||||
{ type: 'success', delayMs: 5 },
|
||||
{ type: 'success', delayMs: 25 },
|
||||
{ type: 'timeout' },
|
||||
]);
|
||||
|
||||
const telemetryLogs = [];
|
||||
const startedAt = Date.now();
|
||||
const result = await publishLock(fixture.relays, makeEvent('evt-delayed-quorum'), {
|
||||
getPublishTimeoutMsFn: () => 80,
|
||||
getMinSuccessfulRelayPublishesFn: () => 2,
|
||||
getRelayFallbacksFn: () => [],
|
||||
getMinActiveRelayPoolFn: () => 1,
|
||||
retryAttempts: 1,
|
||||
telemetryLogger: (line) => telemetryLogs.push(line),
|
||||
healthLogger: () => {},
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
|
||||
assert.strictEqual(result.id, 'evt-delayed-quorum');
|
||||
assert.ok(elapsedMs < 160);
|
||||
|
||||
const telemetry = parseTelemetry(telemetryLogs);
|
||||
const quorumSummary = telemetry.find((entry) => entry.event === 'lock_publish_quorum_met');
|
||||
assert.ok(quorumSummary);
|
||||
assert.strictEqual(quorumSummary.successCount, 2);
|
||||
assert.strictEqual(quorumSummary.requiredSuccesses, 2);
|
||||
assert.strictEqual(quorumSummary.relayAttemptedCount, 3);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { evaluateAlertThresholds } from '../src/relay-health.mjs';
|
||||
|
||||
function entry(timestamp, healthyRelays, totalRelays = 3) {
|
||||
return {
|
||||
timestamp,
|
||||
summary: {
|
||||
healthyRelays,
|
||||
totalRelays,
|
||||
allRelaysUnhealthy: healthyRelays === 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('evaluateAlertThresholds', async (t) => {
|
||||
const now = Date.parse('2026-02-15T12:00:00Z');
|
||||
const minute = 60_000;
|
||||
|
||||
await t.test('Happy Path: No alerts when thresholds are not met', () => {
|
||||
const history = [
|
||||
entry(new Date(now - 10 * minute).toISOString(), 3),
|
||||
entry(new Date(now).toISOString(), 3),
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.5,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
assert.equal(result.alerts.length, 0);
|
||||
});
|
||||
|
||||
await t.test('All Relays Down: Triggers alert when duration equals threshold', () => {
|
||||
// 10 minutes down exactly
|
||||
const history = [
|
||||
entry(new Date(now - 10 * minute).toISOString(), 3), // healthy 10 mins ago
|
||||
entry(new Date(now - 5 * minute).toISOString(), 0),
|
||||
entry(new Date(now).toISOString(), 0),
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.1,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
const alert = result.alerts.find(a => a.type === 'all_relays_down_duration');
|
||||
assert.ok(alert);
|
||||
assert.equal(alert.actualMinutes, 10);
|
||||
});
|
||||
|
||||
await t.test('All Relays Down: Does NOT trigger alert just below threshold', () => {
|
||||
// 9 minutes down
|
||||
const history = [
|
||||
entry(new Date(now - 9 * minute).toISOString(), 3), // healthy 9 mins ago
|
||||
entry(new Date(now).toISOString(), 0),
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.1,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
const alert = result.alerts.find(a => a.type === 'all_relays_down_duration');
|
||||
assert.equal(alert, undefined);
|
||||
});
|
||||
|
||||
await t.test('Success Rate: Triggers alert when rate is strictly below threshold', () => {
|
||||
const history = [
|
||||
entry(new Date(now).toISOString(), 1, 4), // 25% healthy
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
// Threshold 0.3 > 0.25 -> Alert
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 999,
|
||||
minSuccessRate: 0.3,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
const alert = result.alerts.find(a => a.type === 'relay_success_rate_below_threshold');
|
||||
assert.ok(alert);
|
||||
assert.equal(alert.actual, 0.25);
|
||||
});
|
||||
|
||||
await t.test('Success Rate: Does NOT trigger alert when rate equals threshold', () => {
|
||||
const history = [
|
||||
entry(new Date(now).toISOString(), 1, 4), // 25% healthy
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
// Threshold 0.25 == 0.25 -> No Alert
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 999,
|
||||
minSuccessRate: 0.25,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
const alert = result.alerts.find(a => a.type === 'relay_success_rate_below_threshold');
|
||||
assert.equal(alert, undefined);
|
||||
});
|
||||
|
||||
await t.test('Success Rate: Does NOT trigger alert when rate is above threshold', () => {
|
||||
const history = [
|
||||
entry(new Date(now).toISOString(), 3, 4), // 75% healthy
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
// Threshold 0.5 < 0.75 -> No Alert
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 999,
|
||||
minSuccessRate: 0.5,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
const alert = result.alerts.find(a => a.type === 'relay_success_rate_below_threshold');
|
||||
assert.equal(alert, undefined);
|
||||
});
|
||||
|
||||
await t.test('Multiple Alerts: Both conditions met', () => {
|
||||
// Down for 20 mins (threshold 10) AND rate 0 (threshold 0.5)
|
||||
const history = [
|
||||
entry(new Date(now - 20 * minute).toISOString(), 3), // Healthy 20m ago
|
||||
entry(new Date(now - 10 * minute).toISOString(), 0),
|
||||
entry(new Date(now).toISOString(), 0),
|
||||
];
|
||||
const current = history[history.length - 1];
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.5,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
assert.equal(result.alerts.length, 2);
|
||||
assert.ok(result.alerts.some(a => a.type === 'all_relays_down_duration'));
|
||||
assert.ok(result.alerts.some(a => a.type === 'relay_success_rate_below_threshold'));
|
||||
});
|
||||
|
||||
await t.test('Edge Case: Empty history (should not crash)', () => {
|
||||
const history = [];
|
||||
const current = entry(new Date(now).toISOString(), 3); // current result is healthy
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.5,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
assert.equal(result.alerts.length, 0);
|
||||
});
|
||||
|
||||
await t.test('Edge Case: Empty history but current result is unhealthy', () => {
|
||||
const history = [];
|
||||
const current = entry(new Date(now).toISOString(), 0); // current result is unhealthy
|
||||
|
||||
const result = evaluateAlertThresholds(current, history, {
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.5,
|
||||
windowMinutes: 60,
|
||||
}, { nowMs: now });
|
||||
|
||||
assert.equal(result.alerts.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { summarizeHistory } from '../src/relay-health.mjs';
|
||||
|
||||
function createEntry(timestamp, healthyRelays, totalRelays = 3) {
|
||||
return {
|
||||
timestamp,
|
||||
summary: {
|
||||
healthyRelays,
|
||||
totalRelays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('summarizeHistory - basic statistics calculation', () => {
|
||||
const now = Date.parse('2024-01-01T12:00:00Z');
|
||||
const history = [
|
||||
// Chronological order (oldest to newest)
|
||||
createEntry('2024-01-01T11:45:00Z', 0, 3), // 0% healthy
|
||||
createEntry('2024-01-01T11:50:00Z', 1, 3), // 33% healthy
|
||||
createEntry('2024-01-01T11:55:00Z', 3, 3), // 100% healthy
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
|
||||
assert.equal(result.sampleCount, 3);
|
||||
// Total probes: 9, Total healthy: 4. Rate: 4/9
|
||||
assert.equal(result.successRate, 4 / 9);
|
||||
|
||||
// Last healthy was at 11:55 (5 minutes ago)
|
||||
assert.equal(result.allDownDurationMinutes, 5);
|
||||
});
|
||||
|
||||
test('summarizeHistory - handles empty history', () => {
|
||||
const result = summarizeHistory([], { nowMs: Date.now() });
|
||||
assert.equal(result.sampleCount, 0);
|
||||
assert.equal(result.successRate, 1); // Default when sampleCount is 0
|
||||
assert.equal(result.allDownDurationMinutes, null);
|
||||
});
|
||||
|
||||
test('summarizeHistory - respects time window for stats', () => {
|
||||
const now = Date.parse('2024-01-01T12:00:00Z');
|
||||
const history = [
|
||||
// Chronological order
|
||||
createEntry('2024-01-01T10:00:00Z', 0, 3), // Outside 60m window (120m ago)
|
||||
createEntry('2024-01-01T11:55:00Z', 3, 3), // Inside 60m window (5m ago)
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
|
||||
assert.equal(result.sampleCount, 1);
|
||||
assert.equal(result.successRate, 1); // Only the recent healthy entry counts
|
||||
});
|
||||
|
||||
test('summarizeHistory - allDownDurationMinutes considers outside window entries', () => {
|
||||
const now = Date.parse('2024-01-01T12:00:00Z');
|
||||
const history = [
|
||||
// Chronological order
|
||||
createEntry('2024-01-01T10:00:00Z', 3, 3), // Outside window, healthy
|
||||
createEntry('2024-01-01T11:55:00Z', 0, 3), // Inside window, unhealthy
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
|
||||
assert.equal(result.sampleCount, 1); // Only recent one counted
|
||||
assert.equal(result.successRate, 0); // 0/3 = 0
|
||||
|
||||
// Last healthy was at 10:00 (120 minutes ago)
|
||||
assert.equal(result.allDownDurationMinutes, 120);
|
||||
});
|
||||
|
||||
test('summarizeHistory - allDownDurationMinutes is null if never healthy', () => {
|
||||
const now = Date.parse('2024-01-01T12:00:00Z');
|
||||
const history = [
|
||||
createEntry('2024-01-01T11:55:00Z', 0, 3),
|
||||
createEntry('2024-01-01T10:00:00Z', 0, 3),
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
assert.equal(result.allDownDurationMinutes, null);
|
||||
});
|
||||
|
||||
test('summarizeHistory - allDownDurationMinutes is 0 if currently healthy', () => {
|
||||
const now = Date.parse('2024-01-01T12:00:00Z');
|
||||
const history = [
|
||||
createEntry('2024-01-01T12:00:00Z', 3, 3),
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
assert.equal(result.allDownDurationMinutes, 0);
|
||||
});
|
||||
|
||||
test('summarizeHistory - handles malformed entries gracefully', () => {
|
||||
const now = Date.now();
|
||||
const history = [
|
||||
{}, // Missing everything
|
||||
{ timestamp: 'invalid-date' },
|
||||
{ timestamp: new Date(now).toISOString(), summary: null },
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
|
||||
// Should calculate stats safely (ignoring malformed or treating as 0)
|
||||
assert.equal(typeof result.successRate, 'number');
|
||||
assert.equal(result.sampleCount, 1); // Only the valid timestamp one is counted in 'recent'
|
||||
// That one has summary: null, so totalRelays=0, healthyRelays=0.
|
||||
// relayProbeCount = 0. So successRate = 1.
|
||||
assert.equal(result.successRate, 1);
|
||||
});
|
||||
|
||||
test('summarizeHistory - handles valid timestamp but missing summary', () => {
|
||||
const now = Date.parse('2024-01-01T12:00:00Z');
|
||||
const history = [
|
||||
{ timestamp: '2024-01-01T11:55:00Z' } // Missing summary
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 });
|
||||
|
||||
assert.equal(result.sampleCount, 1);
|
||||
assert.equal(result.successRate, 1); // totalRelays defaults to 0 -> relayProbeCount=0 -> rate=1
|
||||
assert.equal(result.allDownDurationMinutes, null); // healthyRelays defaults to 0 -> never healthy
|
||||
});
|
||||
|
||||
test('summarizeHistory - healthy entry with invalid timestamp results in NaN duration', () => {
|
||||
const now = Date.now();
|
||||
const history = [
|
||||
{ timestamp: 'invalid-date', summary: { healthyRelays: 1 } }
|
||||
];
|
||||
|
||||
const result = summarizeHistory(history, { nowMs: now });
|
||||
|
||||
assert.equal(Number.isNaN(result.allDownDurationMinutes), true);
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import path from 'node:path';
|
||||
import { getRoster, _resetRosterCache, _setRosterDependencies, _restoreRosterDependencies } from '../src/roster.mjs';
|
||||
|
||||
// Mock dependencies
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(),
|
||||
readFileSync: mock.fn(),
|
||||
};
|
||||
|
||||
const mockLoadTorchConfig = mock.fn();
|
||||
|
||||
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');
|
||||
|
||||
// Mock console.error to suppress warnings
|
||||
const originalConsoleError = console.error;
|
||||
let mockConsoleError;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConsoleError = mock.fn();
|
||||
console.error = mockConsoleError;
|
||||
|
||||
_resetRosterCache();
|
||||
mockFs.existsSync.mock.resetCalls();
|
||||
mockFs.readFileSync.mock.resetCalls();
|
||||
mockLoadTorchConfig.mock.resetCalls();
|
||||
|
||||
// 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(() => '{}');
|
||||
|
||||
// Inject mocks
|
||||
_setRosterDependencies({
|
||||
fs: mockFs,
|
||||
loadTorchConfig: mockLoadTorchConfig
|
||||
});
|
||||
|
||||
// Clear env vars
|
||||
delete process.env.NOSTR_LOCK_DAILY_ROSTER;
|
||||
delete process.env.NOSTR_LOCK_WEEKLY_ROSTER;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
_restoreRosterDependencies();
|
||||
console.error = originalConsoleError;
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it('returns fallback roster when no configuration or files exist', async () => {
|
||||
const roster = await getRoster('daily');
|
||||
assert.ok(Array.isArray(roster));
|
||||
assert.ok(roster.length > 0);
|
||||
assert.ok(roster.includes('audit-agent')); // verification of fallback content
|
||||
});
|
||||
|
||||
it('loads internal module roster if user/cwd rosters are missing', async () => {
|
||||
// User/CWD missing
|
||||
mockFs.existsSync.mock.mockImplementation(() => false);
|
||||
|
||||
// When reading file, return internal roster content
|
||||
mockFs.readFileSync.mock.mockImplementation(() => {
|
||||
return JSON.stringify({ daily: ['internal-daily'], weekly: ['internal-weekly'] });
|
||||
});
|
||||
|
||||
const daily = await getRoster('daily');
|
||||
assert.deepStrictEqual(daily, ['internal-daily']);
|
||||
});
|
||||
|
||||
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 daily = await getRoster('daily');
|
||||
const weekly = await getRoster('weekly');
|
||||
|
||||
assert.deepStrictEqual(daily, ['user-agent-daily']);
|
||||
assert.deepStrictEqual(weekly, ['user-agent-weekly']);
|
||||
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); // Should be cached after first call
|
||||
});
|
||||
|
||||
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 daily = await getRoster('daily');
|
||||
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 }');
|
||||
|
||||
// Should catch error and return fallback
|
||||
const daily = await getRoster('daily');
|
||||
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 daily = await getRoster('daily');
|
||||
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 daily = await getRoster('daily');
|
||||
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 daily = await getRoster('daily');
|
||||
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 daily = await getRoster('daily');
|
||||
assert.deepStrictEqual(daily, ['agent1', 'agent2', 'agent3']);
|
||||
});
|
||||
});
|
||||
@@ -1,723 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SOURCE_SCRIPT = path.resolve('scripts/agent/run-scheduler-cycle.mjs');
|
||||
const SNAPSHOT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '__snapshots__');
|
||||
|
||||
async function runNode(scriptPath, args, { cwd, env }) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractDetailLine(markdownBody) {
|
||||
const line = String(markdownBody)
|
||||
.split(/\r?\n/)
|
||||
.find((entry) => entry.startsWith('- detail: '));
|
||||
return line ? line.slice('- detail: '.length) : '';
|
||||
}
|
||||
|
||||
function normalizePromptFileDetail(detailLine) {
|
||||
return String(detailLine)
|
||||
.replace(/at\s+.*?agent-a\.md:/, 'at <PROMPT_PATH>:')
|
||||
.replace(/in\s+.*?agent-a\.md\./, 'in <PROMPT_PATH>.')
|
||||
.replace(/'[^']*agent-a\.md'/g, "'<PROMPT_PATH>'");
|
||||
}
|
||||
|
||||
async function assertSnapshot(snapshotName, actual) {
|
||||
const expected = await fs.readFile(path.join(SNAPSHOT_DIR, snapshotName), 'utf8');
|
||||
assert.equal(actual, expected);
|
||||
}
|
||||
|
||||
async function setupFixture({
|
||||
memoryPolicy,
|
||||
lockShellBody = '',
|
||||
roster = ['agent-a'],
|
||||
lockHealthPreflight = undefined,
|
||||
schedulerPolicy = {},
|
||||
preflightScript = '#!/usr/bin/env node\nconsole.log(JSON.stringify({ ok: true, relays: ["wss://relay.test"] }));\nprocess.exit(0);\n',
|
||||
}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-memory-policy-'));
|
||||
const scriptsDir = path.join(root, 'scripts', 'agent');
|
||||
const binDir = path.join(root, 'bin');
|
||||
|
||||
await fs.mkdir(path.join(root, 'src', 'prompts'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'src', 'prompts', 'daily'), { recursive: true });
|
||||
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(root, 'src', 'utils.mjs'));
|
||||
await fs.mkdir(scriptsDir, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.symlink(path.resolve('node_modules'), path.join(root, 'node_modules'));
|
||||
} catch (err) {
|
||||
// Ignore if symlink fails (e.g. if node_modules doesn't exist in test env, though it should)
|
||||
console.error('Warning: Failed to symlink node_modules:', err.message);
|
||||
}
|
||||
|
||||
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.copyFile(path.resolve('scripts/agent/scheduler-config.mjs'), path.join(scriptsDir, 'scheduler-config.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-logger.mjs'), path.join(scriptsDir, 'scheduler-logger.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-state.mjs'), path.join(scriptsDir, 'scheduler-state.mjs'));
|
||||
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(root, 'src', 'utils.mjs'));
|
||||
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(root, 'src', 'torch-config.mjs'));
|
||||
await fs.copyFile(path.resolve('src/constants.mjs'), path.join(root, 'src', 'constants.mjs'));
|
||||
await fs.copyFile(path.resolve('src/relay-health.mjs'), path.join(root, 'src', 'relay-health.mjs'));
|
||||
await fs.copyFile(path.resolve('src/lock-ops.mjs'), path.join(root, 'src', 'lock-ops.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(scriptsDir, 'check-relay-health.mjs'), preflightScript, 'utf8');
|
||||
|
||||
await fs.writeFile(path.join(root, 'src', 'prompts', 'roster.json'), JSON.stringify({ daily: roster }, null, 2));
|
||||
for (const agent of roster) {
|
||||
await fs.writeFile(path.join(root, 'src', 'prompts', 'daily', `${agent}.md`), `# ${agent}\n`, 'utf8');
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(root, 'torch-config.json'),
|
||||
JSON.stringify({
|
||||
scheduler: {
|
||||
firstPromptByCadence: { daily: 'agent-a' },
|
||||
handoffCommandByCadence: { daily: 'echo HANDOFF_OK' },
|
||||
validationCommandsByCadence: { daily: ['true'] },
|
||||
memoryPolicyByCadence: { daily: memoryPolicy },
|
||||
...schedulerPolicy,
|
||||
...(lockHealthPreflight === undefined ? {} : { lockHealthPreflight }),
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(binDir, 'npm'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" != "run" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
case "$2" in
|
||||
lock:check:daily)
|
||||
echo '{"excluded":[]}'
|
||||
;;
|
||||
lock:health)
|
||||
shift 2
|
||||
if [[ "\${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
node scripts/agent/check-relay-health.mjs "$@"
|
||||
;;
|
||||
lock:lock)
|
||||
${lockShellBody || ' :'}
|
||||
;;
|
||||
lock:complete|lint)
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
await fs.chmod(path.join(binDir, 'npm'), 0o755);
|
||||
|
||||
return {
|
||||
root,
|
||||
scriptPath: path.join(scriptsDir, 'run-scheduler-cycle.mjs'),
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('fails required memory policy when retrieval/storage evidence is missing', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: {
|
||||
mode: 'required',
|
||||
retrieveCommand: 'echo RETRIEVE_WITHOUT_MARKER',
|
||||
storeCommand: 'echo STORE_WITHOUT_MARKER',
|
||||
retrieveSuccessMarkers: ['MEMORY_RETRIEVED'],
|
||||
storeSuccessMarkers: ['MEMORY_STORED'],
|
||||
retrieveArtifacts: [],
|
||||
storeArtifacts: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 1, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
assert.match(failedBody, /Required memory steps not verified/);
|
||||
});
|
||||
|
||||
test('accepts required memory policy when markers or artifacts are produced', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: {
|
||||
mode: 'required',
|
||||
retrieveCommand: 'echo MEMORY_RETRIEVED && mkdir -p .scheduler-memory && : > .scheduler-memory/retrieve-daily.ok',
|
||||
storeCommand: 'echo MEMORY_STORED && mkdir -p .scheduler-memory && : > .scheduler-memory/store-daily.ok',
|
||||
retrieveSuccessMarkers: ['MEMORY_RETRIEVED'],
|
||||
storeSuccessMarkers: ['MEMORY_STORED'],
|
||||
retrieveArtifacts: ['.scheduler-memory/retrieve-daily.ok'],
|
||||
storeArtifacts: ['.scheduler-memory/store-daily.ok'],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: { ...fixture.env, AGENT_PLATFORM: 'codex' },
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const completedLog = logs.find((name) => name.endsWith('__completed.md'));
|
||||
assert.ok(completedLog);
|
||||
const completedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', completedLog), 'utf8');
|
||||
assert.match(completedBody, /platform: 'codex'/);
|
||||
|
||||
await fs.access(path.join(fixture.root, '.scheduler-memory', 'retrieve-daily.ok'));
|
||||
await fs.access(path.join(fixture.root, '.scheduler-memory', 'store-daily.ok'));
|
||||
});
|
||||
|
||||
test('host-mode scheduler resolves parent config and rewrites torch-prefixed node commands', { concurrency: false }, async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-host-mode-'));
|
||||
const torchDir = path.join(root, 'torch');
|
||||
const scriptsDir = path.join(torchDir, 'scripts', 'agent');
|
||||
const binDir = path.join(root, 'bin');
|
||||
|
||||
await fs.mkdir(path.join(torchDir, 'src', 'prompts', 'daily'), { recursive: true });
|
||||
await fs.mkdir(path.join(torchDir, 'scripts', 'memory'), { recursive: true });
|
||||
await fs.mkdir(scriptsDir, { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await fs.symlink(path.resolve('node_modules'), path.join(torchDir, 'node_modules'));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
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.copyFile(path.resolve('scripts/agent/scheduler-config.mjs'), path.join(scriptsDir, 'scheduler-config.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-logger.mjs'), path.join(scriptsDir, 'scheduler-logger.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-state.mjs'), path.join(scriptsDir, 'scheduler-state.mjs'));
|
||||
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(torchDir, 'src', 'utils.mjs'));
|
||||
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(torchDir, 'src', 'torch-config.mjs'));
|
||||
await fs.copyFile(path.resolve('src/constants.mjs'), path.join(torchDir, 'src', 'constants.mjs'));
|
||||
await fs.copyFile(path.resolve('src/relay-health.mjs'), path.join(torchDir, 'src', 'relay-health.mjs'));
|
||||
await fs.copyFile(path.resolve('src/lock-ops.mjs'), path.join(torchDir, 'src', 'lock-ops.mjs'));
|
||||
|
||||
await fs.writeFile(path.join(torchDir, 'scripts', 'agent', 'verify-run-artifacts.mjs'), '#!/usr/bin/env node\nprocess.exit(0);\n', 'utf8');
|
||||
await fs.writeFile(path.join(torchDir, 'src', 'prompts', 'roster.json'), JSON.stringify({ daily: ['agent-a'] }, null, 2), 'utf8');
|
||||
await fs.writeFile(path.join(torchDir, 'src', 'prompts', 'daily', 'agent-a.md'), '# agent-a\n', 'utf8');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(torchDir, 'scripts', 'memory', 'retrieve.mjs'),
|
||||
'import fs from "node:fs/promises"; await fs.mkdir(".scheduler-memory", { recursive: true }); await fs.writeFile(".scheduler-memory/retrieve-daily.ok", "ok\\n", "utf8"); console.log("MEMORY_RETRIEVED");\n',
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(torchDir, 'scripts', 'memory', 'store.mjs'),
|
||||
'import fs from "node:fs/promises"; await fs.mkdir(".scheduler-memory", { recursive: true }); await fs.writeFile(".scheduler-memory/store-daily.ok", "ok\\n", "utf8"); console.log("MEMORY_STORED");\n',
|
||||
'utf8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(torchDir, 'scripts', 'agent', 'run-selected-prompt.mjs'),
|
||||
'#!/usr/bin/env node\nconsole.log("HANDOFF_OK");\n',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(root, 'torch-config.json'),
|
||||
JSON.stringify({
|
||||
scheduler: {
|
||||
firstPromptByCadence: { daily: 'agent-a' },
|
||||
handoffCommandByCadence: { daily: 'node torch/scripts/agent/run-selected-prompt.mjs' },
|
||||
validationCommandsByCadence: { daily: ['true'] },
|
||||
memoryPolicyByCadence: {
|
||||
daily: {
|
||||
mode: 'required',
|
||||
retrieveCommand: 'node torch/scripts/memory/retrieve.mjs',
|
||||
storeCommand: 'node torch/scripts/memory/store.mjs',
|
||||
retrieveSuccessMarkers: ['MEMORY_RETRIEVED'],
|
||||
storeSuccessMarkers: ['MEMORY_STORED'],
|
||||
retrieveArtifacts: ['.scheduler-memory/retrieve-daily.ok'],
|
||||
storeArtifacts: ['.scheduler-memory/store-daily.ok'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(binDir, 'npm'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" != "run" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
case "$2" in
|
||||
lock:check:daily)
|
||||
echo '{"excluded":[]}'
|
||||
;;
|
||||
lock:lock|lock:complete|lint)
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
await fs.chmod(path.join(binDir, 'npm'), 0o755);
|
||||
|
||||
const result = await runNode(path.join(scriptsDir, 'run-scheduler-cycle.mjs'), ['--cadence', 'daily'], {
|
||||
cwd: torchDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
assert.match(result.stdout, /Status:\s+completed/);
|
||||
await fs.access(path.join(torchDir, '.scheduler-memory', 'retrieve-daily.ok'));
|
||||
await fs.access(path.join(torchDir, '.scheduler-memory', 'store-daily.ok'));
|
||||
});
|
||||
|
||||
test('records backend failure metadata when lock command exits with code 2', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
lockShellBody: ` echo 'publish failed to all relays token=abc123 SECRET_KEY=xyz987' >&2
|
||||
echo 'websocket: connection refused' >&1
|
||||
exit 2`,
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: { ...fixture.env, AGENT_PLATFORM: 'codex' },
|
||||
});
|
||||
|
||||
assert.equal(result.code, 2, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
|
||||
assert.match(failedBody, /reason: Lock backend error/);
|
||||
assert.match(failedBody, /failure_class: 'backend_unavailable'/);
|
||||
assert.match(failedBody, /failure_category: 'lock_backend_error'/);
|
||||
assert.match(failedBody, /backend_category: 'relay_publish_quorum_failure'/);
|
||||
assert.match(failedBody, /lock_command: 'AGENT_PLATFORM=codex npm run lock:lock -- --agent agent-a --cadence daily'/);
|
||||
assert.match(failedBody, /lock_stderr_excerpt: 'publish failed to all relays token=\[REDACTED\] SECRET_KEY=\[REDACTED\]'/);
|
||||
assert.match(failedBody, /lock_stdout_excerpt: 'websocket: connection refused'/);
|
||||
assert.match(failedBody, /lock_failure_reason_distribution: '\{\}'/);
|
||||
assert.match(failedBody, /lock_attempt_id: '3'/);
|
||||
assert.match(failedBody, /lock_correlation_id: 'daily:agent-a:/);
|
||||
assert.match(failedBody, /Likely backend\/relay connectivity issue during lock acquisition\./);
|
||||
assert.match(failedBody, /Retry command: npm run scheduler:daily/);
|
||||
assert.match(failedBody, /Recommended auto-remediation:/);
|
||||
assert.match(failedBody, /Run health check: npm run lock:health -- --cadence daily/);
|
||||
assert.match(failedBody, /Review incident runbook: docs\/agent-handoffs\/learnings\/2026-02-15-relay-health-preflight-job.md/);
|
||||
assert.match(failedBody, /Prompt not executed\./);
|
||||
assert.match(failedBody, /platform: 'codex'/);
|
||||
|
||||
const detail = extractDetailLine(failedBody);
|
||||
await assertSnapshot('lock-backend-error-detail.snapshot.txt', `${detail}\n`);
|
||||
});
|
||||
|
||||
test('defers backend failures in non-strict mode and reuses idempotency key on successful retry', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
schedulerPolicy: {
|
||||
strict_lock: false,
|
||||
degraded_lock_retry_window: 3600000,
|
||||
max_deferrals: 3,
|
||||
},
|
||||
lockShellBody: ` count_file="$PWD/.lock-count"
|
||||
key_log="$PWD/.idempotency-keys"
|
||||
count=0
|
||||
if [[ -f "$count_file" ]]; then
|
||||
count="$(cat "$count_file")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$count_file"
|
||||
echo "\${SCHEDULER_LOCK_IDEMPOTENCY_KEY:-missing}" >> "$key_log"
|
||||
if [[ "$count" -lt 3 ]]; then
|
||||
echo 'relay query timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
:`,
|
||||
});
|
||||
|
||||
const first = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
assert.equal(first.code, 0, `stdout: ${first.stdout}\nstderr: ${first.stderr}`);
|
||||
|
||||
const second = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
assert.equal(second.code, 0, `stdout: ${second.stdout}\nstderr: ${second.stderr}`);
|
||||
|
||||
const third = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
assert.equal(third.code, 0, `stdout: ${third.stdout}\nstderr: ${third.stderr}`);
|
||||
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const deferredLog = logs.find((name) => name.endsWith('__deferred.md'));
|
||||
assert.ok(deferredLog);
|
||||
const deferredBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', deferredLog), 'utf8');
|
||||
assert.match(deferredBody, /failure_class: 'backend_unavailable'/);
|
||||
assert.match(deferredBody, /failure_category: 'lock_backend_error'/);
|
||||
assert.match(deferredBody, /deferral_attempt_count: '\d+'/);
|
||||
|
||||
const keyLog = await fs.readFile(path.join(fixture.root, '.idempotency-keys'), 'utf8');
|
||||
const keys = keyLog.trim().split(/\r?\n/).filter(Boolean);
|
||||
assert.equal(keys.length, 3);
|
||||
assert.equal(keys[0], 'missing');
|
||||
assert.notEqual(keys[1], 'missing');
|
||||
assert.equal(keys[1], keys[2]);
|
||||
});
|
||||
|
||||
test('fails after exceeding non-strict deferral budget', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
schedulerPolicy: {
|
||||
strict_lock: false,
|
||||
degraded_lock_retry_window: 3600000,
|
||||
max_deferrals: 1,
|
||||
},
|
||||
lockShellBody: ` echo 'publish failed to all relays' >&2
|
||||
exit 2`,
|
||||
});
|
||||
|
||||
const first = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
assert.equal(first.code, 0, `stdout: ${first.stdout}\nstderr: ${first.stderr}`);
|
||||
|
||||
const second = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
assert.equal(second.code, 2, `stdout: ${second.stdout}\nstderr: ${second.stderr}`);
|
||||
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
assert.match(failedBody, /failure_class: 'backend_unavailable'/);
|
||||
assert.match(failedBody, /failure_category: 'lock_backend_error'/);
|
||||
assert.match(failedBody, /deferral_attempt_count: '2'/);
|
||||
});
|
||||
|
||||
test('strict lock mode fails immediately without deferral', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
schedulerPolicy: {
|
||||
strict_lock: true,
|
||||
degraded_lock_retry_window: 3600000,
|
||||
max_deferrals: 5,
|
||||
},
|
||||
lockShellBody: ` echo 'relay timeout' >&2
|
||||
exit 2`,
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
assert.equal(result.code, 2, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
assert.equal(logs.some((name) => name.endsWith('__deferred.md')), false);
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('skips lock health preflight by default when not enabled', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
preflightScript: '#!/usr/bin/env node\nconsole.error("preflight should not run");\nprocess.exit(9);\n',
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
assert.ok(logs.some((name) => name.endsWith('__completed.md')));
|
||||
});
|
||||
|
||||
test('fails scheduler with preflight metadata when lock health preflight is enabled', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
lockHealthPreflight: true,
|
||||
preflightScript: `#!/usr/bin/env node
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
relays: ['wss://relay-1.test', 'wss://relay-2.test'],
|
||||
failureCategory: 'relay query timeout',
|
||||
error: 'Relay query timed out'
|
||||
}));
|
||||
process.exit(2);
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 2, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
|
||||
assert.match(failedBody, /reason: Lock backend unavailable preflight/);
|
||||
assert.match(failedBody, /prompt not executed/i);
|
||||
assert.match(failedBody, /failure_category: 'lock_backend_error'/);
|
||||
assert.match(failedBody, /preflight_failure_category: 'relay query timeout'/);
|
||||
assert.match(failedBody, /relay_list: 'wss:\/\/relay-1.test, wss:\/\/relay-2.test'/);
|
||||
});
|
||||
|
||||
|
||||
test('categorizes unreadable prompt files as prompt_parse_error', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
});
|
||||
|
||||
await fs.unlink(path.join(fixture.root, 'src', 'prompts', 'daily', 'agent-a.md'));
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 1, `stdout: ${result.stdout}
|
||||
stderr: ${result.stderr}`);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
assert.match(failedBody, /reason: Prompt file parse\/read failed/);
|
||||
assert.match(failedBody, /failure_category: 'prompt_parse_error'/);
|
||||
assert.doesNotMatch(failedBody, /Recommended auto-remediation|Run health check: npm run lock:health|Retry command: npm run scheduler:/);
|
||||
|
||||
const detail = normalizePromptFileDetail(extractDetailLine(failedBody));
|
||||
await assertSnapshot('prompt-parse-error-detail.snapshot.txt', `${detail}\n`);
|
||||
});
|
||||
|
||||
test('categorizes invalid prompt schema as prompt_schema_error', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
});
|
||||
|
||||
await fs.writeFile(path.join(fixture.root, 'src', 'prompts', 'daily', 'agent-a.md'), 'plain text without heading\n', 'utf8');
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 1, `stdout: ${result.stdout}
|
||||
stderr: ${result.stderr}`);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
assert.match(failedBody, /reason: Prompt file schema validation failed/);
|
||||
assert.match(failedBody, /failure_category: 'prompt_schema_error'/);
|
||||
});
|
||||
|
||||
test('categorizes handoff runtime failures as execution_error', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
schedulerPolicy: {
|
||||
handoffCommandByCadence: { daily: 'exit 7' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 7, `stdout: ${result.stdout}
|
||||
stderr: ${result.stderr}`);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
assert.match(failedBody, /reason: Prompt\/handoff execution failed/);
|
||||
assert.match(failedBody, /failure_category: 'execution_error'/);
|
||||
});
|
||||
|
||||
test('retries lock acquisition for backend error and succeeds on a later attempt', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
lockShellBody: ` count_file="$PWD/.lock-count"
|
||||
count=0
|
||||
if [[ -f "$count_file" ]]; then
|
||||
count="$(cat "$count_file")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$count_file"
|
||||
if [[ "$count" -lt 2 ]]; then
|
||||
echo 'relay query timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
:`,
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '2',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
assert.match(result.stdout, /"event":"scheduler.lock.retry"/);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
assert.equal(logs.filter((name) => name.endsWith('__failed.md')).length, 0);
|
||||
});
|
||||
|
||||
test('stops retrying after configured lock backend retries are exhausted', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
lockShellBody: ` echo 'publish failed to all relays' >&2
|
||||
exit 2`,
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '1',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 2, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const logs = await fs.readdir(path.join(fixture.root, 'task-logs', 'daily'));
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog);
|
||||
const failedBody = await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', failedLog), 'utf8');
|
||||
|
||||
assert.match(failedBody, /lock_attempts_total: '2'/);
|
||||
assert.match(failedBody, /lock_backoff_schedule_ms: '0'/);
|
||||
assert.match(failedBody, /backend_category: 'relay_publish_quorum_failure'/);
|
||||
});
|
||||
|
||||
test('does not use backend retry flow for exit code 3 lock conflicts', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
memoryPolicy: { mode: 'optional' },
|
||||
lockShellBody: ` count_file="$PWD/.lock-count"
|
||||
count=0
|
||||
if [[ -f "$count_file" ]]; then
|
||||
count="$(cat "$count_file")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$count_file"
|
||||
if [[ "$count" -eq 1 ]]; then
|
||||
exit 3
|
||||
fi
|
||||
:`,
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '3',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
assert.doesNotMatch(result.stdout, /"event":"scheduler.lock.retry"/);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const SCRIPT_PATH = path.resolve('scripts/agent/run-selected-prompt.mjs');
|
||||
|
||||
function runScript({ cwd, env }) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT_PATH], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('runs selected scheduler prompt through configured runner command', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'run-selected-prompt-'));
|
||||
const promptPath = path.join(root, 'prompt.md');
|
||||
await fs.writeFile(promptPath, '# hello from prompt\n', 'utf8');
|
||||
|
||||
const result = await runScript({
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEDULER_PROMPT_PATH: promptPath,
|
||||
SCHEDULER_AGENT: 'agent-a',
|
||||
SCHEDULER_CADENCE: 'daily',
|
||||
SCHEDULER_AGENT_RUNNER_COMMAND: 'node -e "if(!process.env.SCHEDULER_PROMPT_MARKDOWN.includes(\'hello from prompt\')) process.exit(6)"',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
});
|
||||
|
||||
test('propagates non-zero runner exit code', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'run-selected-prompt-fail-'));
|
||||
const promptPath = path.join(root, 'prompt.md');
|
||||
await fs.writeFile(promptPath, '# will fail\n', 'utf8');
|
||||
|
||||
const result = await runScript({
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEDULER_PROMPT_PATH: promptPath,
|
||||
SCHEDULER_AGENT: 'agent-b',
|
||||
SCHEDULER_CADENCE: 'weekly',
|
||||
SCHEDULER_AGENT_RUNNER_COMMAND: 'node -e "process.exit(7)"',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 7, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
});
|
||||
@@ -1,218 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const SOURCE_SCRIPT = path.resolve('scripts/agent/run-scheduler-cycle.mjs');
|
||||
|
||||
const REQUIRED_LOCK_FAILURE_FIELDS = [
|
||||
'status',
|
||||
'reason',
|
||||
'backend_category',
|
||||
'lock_command',
|
||||
'lock_stderr_excerpt',
|
||||
'lock_stdout_excerpt',
|
||||
'detail',
|
||||
];
|
||||
|
||||
const BACKEND_CATEGORY_ENUM = new Set([
|
||||
'relay_query_timeout',
|
||||
'relay_publish_quorum_failure',
|
||||
'websocket_connection_refused_or_dns',
|
||||
'malformed_relay_url_config',
|
||||
'unknown_backend_error',
|
||||
]);
|
||||
|
||||
async function runNode(scriptPath, args, { cwd, env }) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown) {
|
||||
const lines = String(markdown).split(/\r?\n/);
|
||||
if (lines[0] !== '---') return {};
|
||||
const result = {};
|
||||
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line === '---') break;
|
||||
const sep = line.indexOf(':');
|
||||
if (sep < 0) continue;
|
||||
const key = line.slice(0, sep).trim();
|
||||
const raw = line.slice(sep + 1).trim();
|
||||
result[key] = raw.startsWith("'") && raw.endsWith("'")
|
||||
? raw.slice(1, -1).replace(/''/g, "'")
|
||||
: raw;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateLockBackendFailureFrontmatter(frontmatter) {
|
||||
for (const key of REQUIRED_LOCK_FAILURE_FIELDS) {
|
||||
assert.equal(typeof frontmatter[key], 'string', `expected ${key} to be a string`);
|
||||
assert.ok(frontmatter[key].trim().length > 0, `expected ${key} to be non-empty`);
|
||||
}
|
||||
|
||||
assert.equal(frontmatter.status, 'failed', 'expected status=failed for lock-backend failure artifact');
|
||||
assert.ok(
|
||||
BACKEND_CATEGORY_ENUM.has(frontmatter.backend_category),
|
||||
`expected backend_category to be canonical enum, received: ${frontmatter.backend_category}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function setupFixture({ lockShellBody }) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-lock-schema-'));
|
||||
const scriptsDir = path.join(root, 'scripts', 'agent');
|
||||
const binDir = path.join(root, 'bin');
|
||||
|
||||
await fs.mkdir(path.join(root, 'src', 'prompts', 'daily'), { recursive: true });
|
||||
await fs.mkdir(scriptsDir, { recursive: true });
|
||||
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.copyFile(path.resolve('scripts/agent/scheduler-config.mjs'), path.join(scriptsDir, 'scheduler-config.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-logger.mjs'), path.join(scriptsDir, 'scheduler-logger.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-state.mjs'), path.join(scriptsDir, 'scheduler-state.mjs'));
|
||||
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(root, 'src', 'utils.mjs'));
|
||||
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(root, 'src', 'torch-config.mjs'));
|
||||
await fs.copyFile(path.resolve('src/constants.mjs'), path.join(root, 'src', 'constants.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');
|
||||
await fs.writeFile(
|
||||
path.join(root, 'torch-config.json'),
|
||||
JSON.stringify({
|
||||
scheduler: {
|
||||
strict_lock: true,
|
||||
firstPromptByCadence: { daily: 'agent-a' },
|
||||
handoffCommandByCadence: { daily: 'echo HANDOFF_OK' },
|
||||
validationCommandsByCadence: { daily: ['true'] },
|
||||
memoryPolicyByCadence: { daily: { mode: 'optional' } },
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(binDir, 'npm'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" != "run" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
case "$2" in
|
||||
lock:check:daily)
|
||||
echo '{"excluded":[]}'
|
||||
;;
|
||||
lock:lock)
|
||||
${lockShellBody}
|
||||
;;
|
||||
lock:complete|lint)
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
await fs.chmod(path.join(binDir, 'npm'), 0o755);
|
||||
|
||||
return {
|
||||
root,
|
||||
scriptPath: path.join(scriptsDir, 'run-scheduler-cycle.mjs'),
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getFailedFrontmatter(root) {
|
||||
const logDir = path.join(root, 'task-logs', 'daily');
|
||||
const logs = await fs.readdir(logDir);
|
||||
const failedLog = logs.find((name) => name.endsWith('__failed.md'));
|
||||
assert.ok(failedLog, `expected __failed.md in ${logDir}`);
|
||||
const content = await fs.readFile(path.join(logDir, failedLog), 'utf8');
|
||||
return parseFrontmatter(content);
|
||||
}
|
||||
|
||||
test('scheduler lock backend failure artifact matches required frontmatter schema', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
lockShellBody: "echo 'lock_publish_quorum_failed error_category=relay_publish_quorum_failure publish failed to all relays' >&2\n echo 'relay publish failed' >&1\n exit 2",
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 2, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const frontmatter = await getFailedFrontmatter(fixture.root);
|
||||
validateLockBackendFailureFrontmatter(frontmatter);
|
||||
assert.equal(frontmatter.backend_category, 'relay_publish_quorum_failure');
|
||||
});
|
||||
|
||||
test('schema contract catches missing required lock-backend field', () => {
|
||||
const frontmatter = {
|
||||
status: 'failed',
|
||||
reason: 'Lock backend error',
|
||||
backend_category: 'relay_publish_quorum_failure',
|
||||
lock_command: 'npm run lock:lock -- --agent agent-a --cadence daily',
|
||||
lock_stderr_excerpt: 'relay publish failed',
|
||||
lock_stdout_excerpt: 'retrying',
|
||||
detail: 'Prompt not executed.',
|
||||
};
|
||||
|
||||
delete frontmatter.lock_stdout_excerpt;
|
||||
|
||||
assert.throws(
|
||||
() => validateLockBackendFailureFrontmatter(frontmatter),
|
||||
/expected lock_stdout_excerpt to be a string/,
|
||||
);
|
||||
});
|
||||
|
||||
test('schema contract catches misnamed lock-backend key', () => {
|
||||
const frontmatter = {
|
||||
status: 'failed',
|
||||
reason: 'Lock backend error',
|
||||
backend_category: 'relay_publish_quorum_failure',
|
||||
lock_command: 'npm run lock:lock -- --agent agent-a --cadence daily',
|
||||
lock_stderr_excerpt: 'relay publish failed',
|
||||
lock_stdout_excerpt_misnamed: 'retrying',
|
||||
detail: 'Prompt not executed.',
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => validateLockBackendFailureFrontmatter(frontmatter),
|
||||
/expected lock_stdout_excerpt to be a string/,
|
||||
);
|
||||
});
|
||||
@@ -1,242 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { detectPlatform } from '../src/utils.mjs';
|
||||
|
||||
const SOURCE_SCRIPT = path.resolve('scripts/agent/run-scheduler-cycle.mjs');
|
||||
|
||||
async function runNode(scriptPath, args, { cwd, env }) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath, ...args], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function setupFixture({ lockShellBody = ':', schedulerPolicy = {} }) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduler-lock-e2e-'));
|
||||
const scriptsDir = path.join(root, 'scripts', 'agent');
|
||||
const binDir = path.join(root, 'bin');
|
||||
|
||||
await fs.mkdir(path.join(root, 'src', 'prompts', 'daily'), { recursive: true });
|
||||
await fs.mkdir(scriptsDir, { recursive: true });
|
||||
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.copyFile(path.resolve('scripts/agent/scheduler-config.mjs'), path.join(scriptsDir, 'scheduler-config.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-logger.mjs'), path.join(scriptsDir, 'scheduler-logger.mjs'));
|
||||
await fs.copyFile(path.resolve('scripts/agent/scheduler-state.mjs'), path.join(scriptsDir, 'scheduler-state.mjs'));
|
||||
await fs.copyFile(path.resolve('src/utils.mjs'), path.join(root, 'src', 'utils.mjs'));
|
||||
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(root, 'src', 'torch-config.mjs'));
|
||||
await fs.copyFile(path.resolve('src/constants.mjs'), path.join(root, 'src', 'constants.mjs'));
|
||||
await fs.copyFile(path.resolve('src/torch-config.mjs'), path.join(root, 'src', 'torch-config.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));
|
||||
await fs.writeFile(path.join(root, 'src', 'prompts', 'daily', 'agent-a.md'), '# agent-a\n', 'utf8');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(root, 'torch-config.json'),
|
||||
JSON.stringify({
|
||||
scheduler: {
|
||||
firstPromptByCadence: { daily: 'agent-a' },
|
||||
handoffCommandByCadence: { daily: 'echo HANDOFF_OK' },
|
||||
validationCommandsByCadence: { daily: ['true'] },
|
||||
memoryPolicyByCadence: { daily: { mode: 'optional' } },
|
||||
...schedulerPolicy,
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(binDir, 'npm'),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$1" != "run" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
case "$2" in
|
||||
lock:check:daily)
|
||||
echo '{"excluded":[]}'
|
||||
;;
|
||||
lock:lock)
|
||||
${lockShellBody}
|
||||
;;
|
||||
lock:complete|lint)
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
await fs.chmod(path.join(binDir, 'npm'), 0o755);
|
||||
|
||||
return {
|
||||
root,
|
||||
scriptPath: path.join(scriptsDir, 'run-scheduler-cycle.mjs'),
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown) {
|
||||
const lines = String(markdown).split(/\r?\n/);
|
||||
if (lines[0] !== '---') return {};
|
||||
const result = {};
|
||||
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line === '---') break;
|
||||
const sep = line.indexOf(':');
|
||||
if (sep < 0) continue;
|
||||
const key = line.slice(0, sep).trim();
|
||||
const raw = line.slice(sep + 1).trim();
|
||||
result[key] = raw.startsWith("'") && raw.endsWith("'")
|
||||
? raw.slice(1, -1).replace(/''/g, "'")
|
||||
: raw;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readLogByStatus(root, status) {
|
||||
const logDir = path.join(root, 'task-logs', 'daily');
|
||||
const logs = await fs.readdir(logDir);
|
||||
const file = logs.find((name) => name.endsWith(`__${status}.md`));
|
||||
assert.ok(file, `expected ${status} log in ${logDir}; saw: ${logs.join(', ')}`);
|
||||
const content = await fs.readFile(path.join(logDir, file), 'utf8');
|
||||
return { file, content, frontmatter: parseFrontmatter(content) };
|
||||
}
|
||||
|
||||
test('lock preflight e2e: successful lock writes completed status snapshot', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({ lockShellBody: ':' });
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const { frontmatter } = await readLogByStatus(fixture.root, 'completed');
|
||||
assert.deepEqual(
|
||||
{
|
||||
cadence: frontmatter.cadence,
|
||||
agent: frontmatter.agent,
|
||||
status: frontmatter.status,
|
||||
platform: frontmatter.platform,
|
||||
},
|
||||
{
|
||||
cadence: 'daily',
|
||||
agent: 'agent-a',
|
||||
status: 'completed',
|
||||
platform: detectPlatform() || 'unknown',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('lock preflight e2e: exit code 2 quorum failure persists failed backend status and prompt-not-started marker', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({
|
||||
schedulerPolicy: {
|
||||
strict_lock: true,
|
||||
},
|
||||
lockShellBody: "echo 'lock_publish_quorum_failed error_category=relay_publish_quorum_failure publish failed to all relays' >&2\n exit 2",
|
||||
});
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...fixture.env,
|
||||
SCHEDULER_LOCK_MAX_RETRIES: '0',
|
||||
SCHEDULER_LOCK_BACKOFF_MS: '0',
|
||||
SCHEDULER_LOCK_JITTER_MS: '0',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.code, 2, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const { content, frontmatter } = await readLogByStatus(fixture.root, 'failed');
|
||||
assert.deepEqual(
|
||||
{
|
||||
status: frontmatter.status,
|
||||
failure_category: frontmatter.failure_category,
|
||||
failure_class: frontmatter.failure_class,
|
||||
backend_category: frontmatter.backend_category,
|
||||
},
|
||||
{
|
||||
status: 'failed',
|
||||
failure_category: 'lock_backend_error',
|
||||
failure_class: 'backend_unavailable',
|
||||
backend_category: 'relay_publish_quorum_failure',
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(content, /Prompt not executed\./i);
|
||||
assert.doesNotMatch(content, /prompt_parse_error|prompt_schema_error/i);
|
||||
|
||||
const stateRaw = JSON.parse(await fs.readFile(path.join(fixture.root, 'task-logs', 'daily', '.scheduler-run-state.json'), 'utf8'));
|
||||
assert.deepEqual(
|
||||
{
|
||||
has_run_date: typeof stateRaw.run_date === 'string' && stateRaw.run_date.length > 0,
|
||||
lock_deferral: stateRaw.lock_deferral,
|
||||
},
|
||||
{
|
||||
has_run_date: true,
|
||||
lock_deferral: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('lock preflight e2e: non-lock failure exits failed without prompt parse/schema classification', { concurrency: false }, async () => {
|
||||
const fixture = await setupFixture({ lockShellBody: "echo 'unexpected lock command crash' >&2\n exit 9" });
|
||||
|
||||
const result = await runNode(fixture.scriptPath, ['--cadence', 'daily'], {
|
||||
cwd: fixture.root,
|
||||
env: fixture.env,
|
||||
});
|
||||
|
||||
assert.equal(result.code, 9, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
|
||||
const { content, frontmatter } = await readLogByStatus(fixture.root, 'failed');
|
||||
assert.deepEqual(
|
||||
{
|
||||
status: frontmatter.status,
|
||||
reason_line: /- reason: (.+)/.exec(content)?.[1] ?? null,
|
||||
failure_category: frontmatter.failure_category ?? null,
|
||||
},
|
||||
{
|
||||
status: 'failed',
|
||||
reason_line: 'Failed to acquire lock',
|
||||
failure_category: null,
|
||||
},
|
||||
);
|
||||
assert.doesNotMatch(content, /prompt_parse_error|prompt_schema_error/i);
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdCheck } from '../src/lib.mjs';
|
||||
|
||||
describe('Scheduler Ratchet Logic (Log Checking)', () => {
|
||||
const mockRelays = ['wss://relay.test'];
|
||||
const mockNamespace = 'test-ns';
|
||||
const mockTtl = 3600;
|
||||
const mockRoster = ['agentA', 'agentB', 'agentC'];
|
||||
const mockDateStr = '2023-10-27'; // A Friday
|
||||
const mockIsoWeekStr = '2023-W43';
|
||||
|
||||
// Base dependencies for cmdCheck
|
||||
const baseDeps = {
|
||||
getRelays: () => mockRelays,
|
||||
getNamespace: () => mockNamespace,
|
||||
getTtl: () => mockTtl,
|
||||
getRoster: () => mockRoster,
|
||||
loadTorchConfig: () => ({ scheduler: { paused: { daily: [], weekly: [] } } }),
|
||||
getDateStr: () => mockDateStr,
|
||||
getIsoWeek: () => mockIsoWeekStr,
|
||||
queryLocks: async () => [], // No active locks by default
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
// Default readdir mock: returns empty array
|
||||
readdir: async () => [],
|
||||
logDir: 'mock-logs',
|
||||
};
|
||||
|
||||
it('excludes agents completed today (daily)', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
readdir: async () => [
|
||||
'2023-10-27T10-00-00Z__agentA__completed.md', // Matching date
|
||||
'2023-10-26T10-00-00Z__agentB__completed.md', // Old date
|
||||
'2023-10-27T12-00-00Z__agentC__failed.md', // Failed today
|
||||
],
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.deepStrictEqual(result.completed, ['agentA']);
|
||||
assert.ok(result.excluded.includes('agentA'));
|
||||
assert.ok(!result.excluded.includes('agentB')); // Completed yesterday, not excluded today
|
||||
assert.ok(!result.excluded.includes('agentC')); // Failed, not excluded
|
||||
assert.deepStrictEqual(result.available, ['agentB', 'agentC']);
|
||||
});
|
||||
|
||||
it('excludes agents completed this week (weekly)', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
getIsoWeek: (date) => {
|
||||
if (!date) return mockIsoWeekStr;
|
||||
// Mock simple week logic: 2023-10-23 to 2023-10-29 is W43
|
||||
if (date >= '2023-10-23' && date <= '2023-10-29') return '2023-W43';
|
||||
return '2023-W42';
|
||||
},
|
||||
readdir: async () => [
|
||||
'2023-10-23T10-00-00Z__agentA__completed.md', // Monday (in week)
|
||||
'2023-10-20T10-00-00Z__agentB__completed.md', // Last week
|
||||
],
|
||||
};
|
||||
|
||||
const result = await cmdCheck('weekly', deps);
|
||||
|
||||
assert.deepStrictEqual(result.completed, ['agentA']);
|
||||
assert.ok(result.excluded.includes('agentA'));
|
||||
assert.ok(!result.excluded.includes('agentB'));
|
||||
assert.deepStrictEqual(result.available, ['agentB', 'agentC']);
|
||||
});
|
||||
|
||||
it('combines locked and completed agents in exclusion list', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
queryLocks: async () => [{ agent: 'agentB', eventId: 'ev1' }], // agentB is locked
|
||||
readdir: async () => [
|
||||
'2023-10-27T10-00-00Z__agentA__completed.md', // agentA is completed
|
||||
],
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.deepStrictEqual(result.completed, ['agentA']);
|
||||
assert.deepStrictEqual(result.locked, ['agentB']);
|
||||
|
||||
// agentA (completed) and agentB (locked) should be excluded
|
||||
assert.ok(result.excluded.includes('agentA'));
|
||||
assert.ok(result.excluded.includes('agentB'));
|
||||
assert.deepStrictEqual(result.available, ['agentC']);
|
||||
});
|
||||
|
||||
it('ignores logs when --ignore-logs is set', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
readdir: async () => [
|
||||
'2023-10-27T10-00-00Z__agentA__completed.md',
|
||||
],
|
||||
ignoreLogs: true,
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
|
||||
assert.deepStrictEqual(result.completed, []);
|
||||
assert.ok(!result.excluded.includes('agentA'));
|
||||
assert.deepStrictEqual(result.available, ['agentA', 'agentB', 'agentC']);
|
||||
});
|
||||
|
||||
it('handles missing log directory gracefully', async () => {
|
||||
const deps = {
|
||||
...baseDeps,
|
||||
readdir: async () => {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
|
||||
const result = await cmdCheck('daily', deps);
|
||||
assert.deepStrictEqual(result.completed, []);
|
||||
assert.deepStrictEqual(result.available, ['agentA', 'agentB', 'agentC']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Scheduler cycle ordering guarantees', () => {
|
||||
it('keeps optional preflight before lock-acquire and preserves handoff/artifact/validation/complete ordering', 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 order = [
|
||||
"const preflight = await runLockHealthPreflight({ cadence, platform });",
|
||||
"const checkResult = await runCommand('npm', ['run', `lock:check:${cadence}`",
|
||||
"const lockAttempt = await acquireLockWithRetry({",
|
||||
"if (schedulerConfig.handoffCommand)",
|
||||
"const artifactCheck = await runCommand('node', [",
|
||||
"for (const validation of schedulerConfig.validationCommands)",
|
||||
"const completeResult = await runCommand(",
|
||||
"await writeLog({ cadence, agent: selectedAgent, status: 'completed'",
|
||||
];
|
||||
|
||||
let previousIndex = -1;
|
||||
for (const token of order) {
|
||||
const index = source.indexOf(token);
|
||||
assert.ok(index >= 0, `Expected token not found: ${token}`);
|
||||
assert.ok(index > previousIndex, `Token out of order: ${token}`);
|
||||
previousIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
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 = [
|
||||
"backend_category: backendCategory",
|
||||
"lock_command: lockCommand",
|
||||
"lock_stderr_excerpt: stderrExcerpt || '(empty)'",
|
||||
"lock_stdout_excerpt: stdoutExcerpt || '(empty)'",
|
||||
"reason: 'Lock backend error'",
|
||||
];
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,310 +0,0 @@
|
||||
/**
|
||||
* Unit tests for the scheduler time-window guard.
|
||||
*
|
||||
* Scenario-first, cheat-resistant (Dark Factory standard):
|
||||
* - Every test asserts observable outcomes at the boundary of
|
||||
* buildRecentlyRunExclusionSet (the Set it returns).
|
||||
* - Determinism controls: `now` is injected so the clock is fixed.
|
||||
* - No mocking of internal logic; real temp-directory I/O is used so
|
||||
* the implementation cannot "return true" and pass.
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
buildRecentlyRunExclusionSet,
|
||||
parseDateValue,
|
||||
parseTimestampFromFilename,
|
||||
parseAgentFromFilename,
|
||||
isStrictSchedulerLogFilename,
|
||||
parseFrontmatterCreatedAt,
|
||||
parseFrontmatterAgent,
|
||||
CADENCE_WINDOW_MS,
|
||||
} from '../scripts/agent/scheduler-utils.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a task log file with optional YAML frontmatter. */
|
||||
async function writeLog(dir, filename, { createdAt } = {}) {
|
||||
const frontmatter = createdAt
|
||||
? `---\nagent: test\ncreated_at: ${createdAt}\n---\n`
|
||||
: '';
|
||||
await fs.writeFile(path.join(dir, filename), `${frontmatter}# log\n`, 'utf8');
|
||||
}
|
||||
|
||||
/** ISO string with colons replaced by dashes (canonical log filename format). */
|
||||
function isoToFilename(isoString) {
|
||||
return isoString.replace(/:/g, '-').replace(/\.\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
/** Build a canonical log filename for a given agent/status/time. */
|
||||
function logFilename(agent, status, isoString) {
|
||||
return `${isoToFilename(isoString)}__${agent}__${status}.md`;
|
||||
}
|
||||
|
||||
// Fixed reference point: 2026-02-20T12:00:00Z
|
||||
const NOW = new Date('2026-02-20T12:00:00Z').getTime();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests for helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseDateValue', () => {
|
||||
it('parses a valid ISO string', () => {
|
||||
assert.equal(parseDateValue('2026-02-20T12:00:00Z'), NOW);
|
||||
});
|
||||
|
||||
it('returns null for empty input', () => {
|
||||
assert.equal(parseDateValue(''), null);
|
||||
assert.equal(parseDateValue(null), null);
|
||||
assert.equal(parseDateValue(undefined), null);
|
||||
});
|
||||
|
||||
it('returns null for invalid date strings', () => {
|
||||
assert.equal(parseDateValue('not-a-date'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStrictSchedulerLogFilename', () => {
|
||||
it('accepts valid completed log', () => {
|
||||
assert.ok(isStrictSchedulerLogFilename('2026-02-20T12-00-00Z__audit-agent__completed.md'));
|
||||
});
|
||||
|
||||
it('accepts valid failed log', () => {
|
||||
assert.ok(isStrictSchedulerLogFilename('2026-02-20T12-00-00Z__audit-agent__failed.md'));
|
||||
});
|
||||
|
||||
it('rejects deferred logs', () => {
|
||||
assert.ok(!isStrictSchedulerLogFilename('2026-02-20T12-00-00Z__audit-agent__deferred.md'));
|
||||
});
|
||||
|
||||
it('rejects filenames without timestamp', () => {
|
||||
assert.ok(!isStrictSchedulerLogFilename('audit-agent__completed.md'));
|
||||
});
|
||||
|
||||
it('rejects plain text files', () => {
|
||||
assert.ok(!isStrictSchedulerLogFilename('notes.md'));
|
||||
});
|
||||
|
||||
it('rejects .scheduler-run-state.json', () => {
|
||||
assert.ok(!isStrictSchedulerLogFilename('.scheduler-run-state.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAgentFromFilename', () => {
|
||||
it('extracts agent name from completed log', () => {
|
||||
assert.equal(parseAgentFromFilename('2026-02-20T12-00-00Z__audit-agent__completed.md'), 'audit-agent');
|
||||
});
|
||||
|
||||
it('extracts agent name from failed log', () => {
|
||||
assert.equal(parseAgentFromFilename('2026-02-20T12-00-00Z__ci-health-agent__failed.md'), 'ci-health-agent');
|
||||
});
|
||||
|
||||
it('returns null for invalid filename', () => {
|
||||
assert.equal(parseAgentFromFilename('bad-file.md'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimestampFromFilename', () => {
|
||||
it('parses timestamp from canonical filename', () => {
|
||||
const filename = '2026-02-20T12-00-00Z__audit-agent__completed.md';
|
||||
assert.equal(parseTimestampFromFilename(filename), NOW);
|
||||
});
|
||||
|
||||
it('returns null for non-canonical filename', () => {
|
||||
assert.equal(parseTimestampFromFilename('audit-agent__completed.md'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFrontmatterCreatedAt / parseFrontmatterAgent', () => {
|
||||
const doc = `---\nagent: known-issues-agent\ncreated_at: 2026-02-20T12:00:00Z\nstatus: completed\n---\n# body\n`;
|
||||
|
||||
it('reads created_at from frontmatter', () => {
|
||||
assert.equal(parseFrontmatterCreatedAt(doc), '2026-02-20T12:00:00Z');
|
||||
});
|
||||
|
||||
it('reads agent from frontmatter', () => {
|
||||
assert.equal(parseFrontmatterAgent(doc), 'known-issues-agent');
|
||||
});
|
||||
|
||||
it('returns null when no frontmatter', () => {
|
||||
assert.equal(parseFrontmatterCreatedAt('# no frontmatter'), null);
|
||||
assert.equal(parseFrontmatterAgent('# no frontmatter'), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario tests for buildRecentlyRunExclusionSet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildRecentlyRunExclusionSet — scenarios', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-time-window-'));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// SCN-TWG-01
|
||||
it('SCN-TWG-01: excludes agents with completed logs within 24-hour daily window', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn01-'));
|
||||
// Ran 1 hour ago — within the 24h window
|
||||
const recentTs = new Date(NOW - 60 * 60 * 1000).toISOString();
|
||||
await writeLog(dir, logFilename('audit-agent', 'completed', recentTs));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(result.has('audit-agent'), 'audit-agent ran 1h ago should be excluded');
|
||||
assert.equal(result.size, 1);
|
||||
});
|
||||
|
||||
// SCN-TWG-02
|
||||
it('SCN-TWG-02: does NOT exclude agents whose last run is outside the 24-hour window', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn02-'));
|
||||
// Ran 25 hours ago — outside the window
|
||||
const oldTs = new Date(NOW - 25 * 60 * 60 * 1000).toISOString();
|
||||
await writeLog(dir, logFilename('audit-agent', 'completed', oldTs));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(!result.has('audit-agent'), 'agent outside window should not be excluded');
|
||||
assert.equal(result.size, 0);
|
||||
});
|
||||
|
||||
// SCN-TWG-03
|
||||
it('SCN-TWG-03: excludes agents with failed logs within the window', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn03-'));
|
||||
const recentTs = new Date(NOW - 30 * 60 * 1000).toISOString(); // 30 min ago
|
||||
await writeLog(dir, logFilename('ci-health-agent', 'failed', recentTs));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(result.has('ci-health-agent'), 'recently failed agent should be excluded');
|
||||
});
|
||||
|
||||
// SCN-TWG-04
|
||||
it('SCN-TWG-04: cross-midnight scenario — agent ran 10 min before midnight, checked 10 min after', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn04-'));
|
||||
const tenMinutesAgo = new Date(NOW - 20 * 60 * 1000).toISOString(); // 20 min ago (simulates cross-midnight)
|
||||
await writeLog(dir, logFilename('load-test-agent', 'completed', tenMinutesAgo));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(result.has('load-test-agent'), 'cross-midnight agent should still be excluded');
|
||||
});
|
||||
|
||||
// SCN-TWG-05
|
||||
it('SCN-TWG-05: uses frontmatter created_at over filename timestamp when both present', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn05-'));
|
||||
|
||||
// Filename timestamp = 26h ago (outside window), but frontmatter says 1h ago (inside window)
|
||||
const oldTs = new Date(NOW - 26 * 60 * 60 * 1000).toISOString();
|
||||
const recentFrontmatter = new Date(NOW - 60 * 60 * 1000).toISOString();
|
||||
await writeLog(dir, logFilename('docs-agent', 'completed', oldTs), { createdAt: recentFrontmatter });
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(result.has('docs-agent'), 'frontmatter created_at should take precedence over filename timestamp');
|
||||
});
|
||||
|
||||
// SCN-TWG-06
|
||||
it('SCN-TWG-06: weekly window uses 7-day threshold', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn06-'));
|
||||
|
||||
// 5 days ago — within 7-day window
|
||||
const inWindowTs = new Date(NOW - 5 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await writeLog(dir, logFilename('changelog-agent', 'completed', inWindowTs));
|
||||
|
||||
// 8 days ago — outside 7-day window
|
||||
const outWindowTs = new Date(NOW - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||
await writeLog(dir, logFilename('dead-code-agent', 'completed', outWindowTs));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'weekly', NOW);
|
||||
|
||||
assert.ok(result.has('changelog-agent'), 'agent 5d ago should be excluded for weekly');
|
||||
assert.ok(!result.has('dead-code-agent'), 'agent 8d ago should not be excluded for weekly');
|
||||
});
|
||||
|
||||
// SCN-TWG-07
|
||||
it('SCN-TWG-07: empty log directory returns empty set', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn07-'));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.equal(result.size, 0);
|
||||
});
|
||||
|
||||
// SCN-TWG-08
|
||||
it('SCN-TWG-08: non-existent directory returns empty set without throwing', async () => {
|
||||
const nonExistent = path.join(tmpDir, 'does-not-exist-' + Date.now());
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(nonExistent, 'daily', NOW);
|
||||
|
||||
assert.equal(result.size, 0);
|
||||
});
|
||||
|
||||
// SCN-TWG-09
|
||||
it('SCN-TWG-09: only the most recent log matters — agent excluded if any log is within window', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn09-'));
|
||||
|
||||
// Two logs: one old, one recent
|
||||
const oldTs = new Date(NOW - 25 * 60 * 60 * 1000).toISOString();
|
||||
const recentTs = new Date(NOW - 2 * 60 * 60 * 1000).toISOString();
|
||||
await writeLog(dir, logFilename('perf-agent', 'completed', oldTs));
|
||||
await writeLog(dir, logFilename('perf-agent', 'failed', recentTs));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(result.has('perf-agent'), 'agent with any log within window should be excluded');
|
||||
});
|
||||
|
||||
// SCN-TWG-10
|
||||
it('SCN-TWG-10: mixed roster — correctly partitions in-window vs out-of-window agents', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn10-'));
|
||||
|
||||
const recentTs = new Date(NOW - 1 * 60 * 60 * 1000).toISOString(); // 1h ago
|
||||
const oldTs = new Date(NOW - 30 * 60 * 60 * 1000).toISOString(); // 30h ago
|
||||
|
||||
await writeLog(dir, logFilename('audit-agent', 'completed', recentTs));
|
||||
await writeLog(dir, logFilename('ci-health-agent', 'completed', oldTs));
|
||||
await writeLog(dir, logFilename('log-fixer-agent', 'failed', recentTs));
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.ok(result.has('audit-agent'));
|
||||
assert.ok(!result.has('ci-health-agent'));
|
||||
assert.ok(result.has('log-fixer-agent'));
|
||||
assert.equal(result.size, 2);
|
||||
});
|
||||
|
||||
// SCN-TWG-11 — anti-cheat: verify the guard cannot be trivially bypassed
|
||||
it('SCN-TWG-11: non-log files in directory are ignored', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(tmpDir, 'scn11-'));
|
||||
|
||||
// Write files that are NOT canonical log filenames
|
||||
await fs.writeFile(path.join(dir, '.scheduler-run-state.json'), '{}', 'utf8');
|
||||
await fs.writeFile(path.join(dir, 'README.md'), '# readme', 'utf8');
|
||||
await fs.writeFile(path.join(dir, '2026-02-20T12-00-00Z__audit-agent__deferred.md'), 'deferred', 'utf8');
|
||||
|
||||
const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW);
|
||||
|
||||
assert.equal(result.size, 0, 'non-log files and deferred logs must not trigger exclusion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CADENCE_WINDOW_MS constants', () => {
|
||||
it('daily window is exactly 24 hours', () => {
|
||||
assert.equal(CADENCE_WINDOW_MS.daily, 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('weekly window is exactly 7 days', () => {
|
||||
assert.equal(CADENCE_WINDOW_MS.weekly, 7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const SCRIPT = path.resolve('scripts/agent/summarize-lock-reliability.mjs');
|
||||
|
||||
function runNode(args, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
test('summarizes recent lock reliability by platform/cadence/backend/relay', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lock-reliability-'));
|
||||
await fs.mkdir(path.join(root, 'task-logs', 'daily'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'task-logs', 'weekly'), { recursive: true });
|
||||
|
||||
await fs.writeFile(path.join(root, 'task-logs', 'daily', '2026-02-14T00-00-00Z__agent-a__failed.md'), `---
|
||||
cadence: daily
|
||||
agent: agent-a
|
||||
status: failed
|
||||
platform: codex
|
||||
backend_category: publish failed to all relays
|
||||
relay_list: 'wss://relay-1.test, wss://relay-2.test'
|
||||
---
|
||||
# Scheduler failed
|
||||
- reason: Lock backend error
|
||||
`, 'utf8');
|
||||
|
||||
await fs.writeFile(path.join(root, 'task-logs', 'weekly', '2026-02-14T00-00-01Z__agent-b__failed.md'), `---
|
||||
cadence: weekly
|
||||
agent: agent-b
|
||||
status: failed
|
||||
platform: codex
|
||||
preflight_failure_category: relay query timeout
|
||||
relay_list: 'wss://relay-1.test'
|
||||
---
|
||||
# Scheduler failed
|
||||
- reason: Lock backend unavailable preflight
|
||||
`, 'utf8');
|
||||
|
||||
await fs.writeFile(path.join(root, 'task-logs', 'daily', '2026-02-14T00-00-02Z__agent-c__completed.md'), `---
|
||||
cadence: daily
|
||||
agent: agent-c
|
||||
status: completed
|
||||
platform: claude
|
||||
---
|
||||
# Scheduler completed
|
||||
`, 'utf8');
|
||||
|
||||
const result = await runNode(['--since-days', '365', '--out-dir', 'artifacts/reliability'], root);
|
||||
assert.equal(result.code, 0, `stdout=${result.stdout}\nstderr=${result.stderr}`);
|
||||
|
||||
const jsonReport = JSON.parse(await fs.readFile(path.join(root, 'artifacts', 'reliability', 'lock-reliability-summary.json'), 'utf8'));
|
||||
assert.equal(jsonReport.byPlatform.codex, 2);
|
||||
assert.equal(jsonReport.byPlatform.claude, 1);
|
||||
assert.equal(jsonReport.byCadence.daily, 2);
|
||||
assert.equal(jsonReport.byCadence.weekly, 1);
|
||||
assert.equal(jsonReport.byBackendCategory['publish failed to all relays'], 1);
|
||||
assert.equal(jsonReport.byBackendCategory['preflight:relay query timeout'], 1);
|
||||
assert.equal(jsonReport.byRelayEndpoint['wss://relay-1.test'], 2);
|
||||
|
||||
const markdown = await fs.readFile(path.join(root, 'artifacts', 'reliability', 'lock-reliability-summary.md'), 'utf8');
|
||||
assert.match(markdown, /## By platform/);
|
||||
assert.match(markdown, /- codex: 2/);
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const SCRIPT = path.resolve('scripts/test-audit/run-flaky-check.mjs');
|
||||
|
||||
function runFlakyCheck(args) {
|
||||
return new Promise((resolve) => {
|
||||
const childEnv = { ...process.env };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
delete childEnv.NODE_UNIQUE_ID;
|
||||
|
||||
const child = spawn(process.execPath, [SCRIPT, ...args], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: childEnv,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
test('run-flaky-check reports pass/fail counts from TAP output files', async () => {
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flaky-check-report-'));
|
||||
const passFixture = 'test/fixtures/flaky-check/pass.fixture.test.mjs';
|
||||
const failFixture = 'test/fixtures/flaky-check/fail.fixture.test.mjs';
|
||||
|
||||
const result = await runFlakyCheck(['--output-dir', outputDir, passFixture, failFixture]);
|
||||
assert.equal(result.code, 0, result.stderr || result.stdout);
|
||||
|
||||
const matrix = JSON.parse(await fs.readFile(path.join(outputDir, 'flakiness-matrix.json'), 'utf8'));
|
||||
|
||||
// The tool keys results by test name, not filename
|
||||
assert.deepEqual(matrix['pass fixture stays green'], { pass: 5, fail: 0 });
|
||||
assert.deepEqual(matrix['fail fixture stays red'], { pass: 0, fail: 5 });
|
||||
|
||||
const diagnostics = JSON.parse(await fs.readFile(path.join(outputDir, 'flakiness-runs.json'), 'utf8'));
|
||||
assert.equal(diagnostics.length, 5);
|
||||
assert.ok(diagnostics.every((run) => run.observed >= 2));
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, it, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { loadTorchConfig, _resetTorchConfigCache } from '../src/torch-config.mjs';
|
||||
|
||||
describe('loadTorchConfig (Unit Tests with Mocked FS)', () => {
|
||||
beforeEach(() => {
|
||||
_resetTorchConfigCache();
|
||||
});
|
||||
|
||||
it('loads valid config correctly', async () => {
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(() => true),
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({
|
||||
nostrLock: { namespace: 'unit-test-ns' }
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
const config = await loadTorchConfig(mockFs);
|
||||
|
||||
assert.strictEqual(config.nostrLock.namespace, 'unit-test-ns');
|
||||
// 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', async () => {
|
||||
const mockFs = {
|
||||
promises: {
|
||||
readFile: mock.fn(async () => {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const config = await loadTorchConfig(mockFs);
|
||||
|
||||
assert.strictEqual(config.nostrLock.namespace, null);
|
||||
assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('throws error on malformed JSON', async () => {
|
||||
const mockFs = {
|
||||
promises: {
|
||||
readFile: mock.fn(async () => '{ invalid json }')
|
||||
}
|
||||
};
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await loadTorchConfig(mockFs);
|
||||
}, /Failed to parse/);
|
||||
});
|
||||
|
||||
it('caches the config and ignores subsequent fs calls', async () => {
|
||||
const mockFs = {
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({ nostrLock: { namespace: 'cached' } }))
|
||||
}
|
||||
};
|
||||
|
||||
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 = {
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({ nostrLock: { namespace: 'changed' } }))
|
||||
}
|
||||
};
|
||||
|
||||
const config2 = await loadTorchConfig(mockFs2);
|
||||
|
||||
assert.strictEqual(config2.nostrLock.namespace, 'cached');
|
||||
assert.strictEqual(config1, config2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, it, after, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getTorchConfigPath,
|
||||
parseTorchConfig,
|
||||
loadTorchConfig,
|
||||
_resetTorchConfigCache,
|
||||
getRelays,
|
||||
getQueryTimeoutMs,
|
||||
getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacks,
|
||||
getMinActiveRelayPool,
|
||||
} from '../src/torch-config.mjs';
|
||||
import { DEFAULT_RELAYS } from '../src/constants.mjs';
|
||||
|
||||
describe('torch-config', () => {
|
||||
describe('getTorchConfigPath', () => {
|
||||
const originalEnv = process.env.TORCH_CONFIG_PATH;
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
after(() => {
|
||||
if (originalEnv === undefined) delete process.env.TORCH_CONFIG_PATH;
|
||||
else process.env.TORCH_CONFIG_PATH = originalEnv;
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
it('returns default path when env var is not set', () => {
|
||||
delete process.env.TORCH_CONFIG_PATH;
|
||||
assert.strictEqual(getTorchConfigPath(), path.resolve(process.cwd(), 'torch-config.json'));
|
||||
});
|
||||
|
||||
it('returns custom path when env var is set', () => {
|
||||
process.env.TORCH_CONFIG_PATH = 'custom-config.json';
|
||||
assert.strictEqual(getTorchConfigPath(), path.resolve(process.cwd(), 'custom-config.json'));
|
||||
});
|
||||
|
||||
it('prefers parent torch-config.json in host-repo mode when cwd is <repo>/torch', () => {
|
||||
delete process.env.TORCH_CONFIG_PATH;
|
||||
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(process.cwd(), 'test-torch-config-host-'));
|
||||
const hostPkgPath = path.join(fixtureRoot, 'package.json');
|
||||
const parentConfigPath = path.join(fixtureRoot, 'torch-config.json');
|
||||
const torchDir = path.join(fixtureRoot, 'torch');
|
||||
const localConfigPath = path.join(torchDir, 'torch-config.json');
|
||||
|
||||
fs.mkdirSync(torchDir, { recursive: true });
|
||||
fs.writeFileSync(hostPkgPath, JSON.stringify({ name: 'seedpass', version: '1.0.0' }), 'utf8');
|
||||
fs.writeFileSync(parentConfigPath, JSON.stringify({ nostrLock: { namespace: 'parent' } }), 'utf8');
|
||||
fs.writeFileSync(localConfigPath, JSON.stringify({ nostrLock: { namespace: 'local' } }), 'utf8');
|
||||
|
||||
process.chdir(torchDir);
|
||||
assert.strictEqual(getTorchConfigPath(), path.resolve(parentConfigPath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTorchConfig', () => {
|
||||
it('parses new lock backend knobs', () => {
|
||||
const config = parseTorchConfig({
|
||||
nostrLock: {
|
||||
namespace: ' test ',
|
||||
relays: ['wss://relay1'],
|
||||
relayFallbacks: [' wss://fallback-1 ', 'wss://fallback-2'],
|
||||
queryTimeoutMs: 5000,
|
||||
publishTimeoutMs: 8000,
|
||||
minSuccessfulRelayPublishes: 2,
|
||||
minActiveRelayPool: 2,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(config.nostrLock.namespace, 'test');
|
||||
assert.deepStrictEqual(config.nostrLock.relays, ['wss://relay1']);
|
||||
assert.deepStrictEqual(config.nostrLock.relayFallbacks, ['wss://fallback-1', 'wss://fallback-2']);
|
||||
assert.strictEqual(config.nostrLock.queryTimeoutMs, 5000);
|
||||
assert.strictEqual(config.nostrLock.publishTimeoutMs, 8000);
|
||||
assert.strictEqual(config.nostrLock.minSuccessfulRelayPublishes, 2);
|
||||
assert.strictEqual(config.nostrLock.minActiveRelayPool, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTorchConfig', () => {
|
||||
const tempConfigPath = path.resolve(process.cwd(), 'temp-torch-config.json');
|
||||
const badConfigPath = path.resolve(process.cwd(), 'bad-torch-config.json');
|
||||
const originalEnv = process.env.TORCH_CONFIG_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTorchConfigCache();
|
||||
delete process.env.TORCH_CONFIG_PATH;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath);
|
||||
if (fs.existsSync(badConfigPath)) fs.unlinkSync(badConfigPath);
|
||||
if (originalEnv === undefined) delete process.env.TORCH_CONFIG_PATH;
|
||||
else process.env.TORCH_CONFIG_PATH = originalEnv;
|
||||
_resetTorchConfigCache();
|
||||
});
|
||||
|
||||
it('throws fatal error for invalid relay URL', async () => {
|
||||
fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { relays: ['https://bad'] } }));
|
||||
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
||||
await assert.rejects(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/);
|
||||
});
|
||||
|
||||
it('throws fatal error for invalid timeout range', async () => {
|
||||
fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { queryTimeoutMs: 50 } }));
|
||||
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
||||
await assert.rejects(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/);
|
||||
});
|
||||
|
||||
it('throws error for malformed JSON', async () => {
|
||||
fs.writeFileSync(badConfigPath, '{ invalid json }');
|
||||
process.env.TORCH_CONFIG_PATH = badConfigPath;
|
||||
await assert.rejects(() => loadTorchConfig(), /Failed to parse/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backend getters', () => {
|
||||
const originalEnv = {
|
||||
NOSTR_LOCK_RELAYS: process.env.NOSTR_LOCK_RELAYS,
|
||||
NOSTR_LOCK_QUERY_TIMEOUT_MS: process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS,
|
||||
NOSTR_LOCK_PUBLISH_TIMEOUT_MS: process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS,
|
||||
NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES: process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES,
|
||||
NOSTR_LOCK_RELAY_FALLBACKS: process.env.NOSTR_LOCK_RELAY_FALLBACKS,
|
||||
NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL: process.env.NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTorchConfigCache();
|
||||
delete process.env.NOSTR_LOCK_RELAYS;
|
||||
delete process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS;
|
||||
delete process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS;
|
||||
delete process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES;
|
||||
delete process.env.NOSTR_LOCK_RELAY_FALLBACKS;
|
||||
delete process.env.NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const [k, v] of Object.entries(originalEnv)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
_resetTorchConfigCache();
|
||||
});
|
||||
|
||||
it('returns default relays', async () => {
|
||||
assert.deepStrictEqual(await getRelays(), DEFAULT_RELAYS);
|
||||
});
|
||||
|
||||
it('validates env knob values', async () => {
|
||||
process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '99';
|
||||
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';
|
||||
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';
|
||||
await assert.rejects(() => getMinSuccessfulRelayPublishes(), /NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES/);
|
||||
|
||||
process.env.NOSTR_LOCK_RELAY_FALLBACKS = 'https://bad';
|
||||
await assert.rejects(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/);
|
||||
|
||||
process.env.NOSTR_LOCK_RELAY_FALLBACKS = '';
|
||||
process.env.NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL = '0';
|
||||
await assert.rejects(() => getMinActiveRelayPool(), /NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { withTimeout } from '../src/lock-utils.mjs';
|
||||
|
||||
describe('Async Utilities', () => {
|
||||
describe('withTimeout', () => {
|
||||
it('resolves when promise completes before timeout', async () => {
|
||||
const result = await withTimeout(
|
||||
new Promise((resolve) => setTimeout(() => resolve('success'), 10)),
|
||||
200,
|
||||
'Timed out'
|
||||
);
|
||||
assert.strictEqual(result, 'success');
|
||||
});
|
||||
|
||||
it('rejects when promise times out', async () => {
|
||||
try {
|
||||
await withTimeout(
|
||||
new Promise((resolve) => setTimeout(() => resolve('success'), 200)),
|
||||
10,
|
||||
'Operation timed out'
|
||||
);
|
||||
assert.fail('Should have rejected');
|
||||
} catch (err) {
|
||||
assert.strictEqual(err.message, 'Operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
it('clears timeout on success', async () => {
|
||||
const result = await withTimeout(
|
||||
Promise.resolve('fast'),
|
||||
1000,
|
||||
'timeout'
|
||||
);
|
||||
assert.strictEqual(result, 'fast');
|
||||
});
|
||||
|
||||
it('propagates original promise rejection', async () => {
|
||||
try {
|
||||
await withTimeout(
|
||||
Promise.reject(new Error('Original error')),
|
||||
100,
|
||||
'Timeout'
|
||||
);
|
||||
assert.fail('Should have rejected');
|
||||
} catch (err) {
|
||||
assert.strictEqual(err.message, 'Original error');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, it, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getIsoWeekStr, todayDateStr, nowUnix, ensureDir, detectPlatform, relayListLabel, mergeRelayList } from '../src/utils.mjs';
|
||||
|
||||
describe('Date Utilities', () => {
|
||||
describe('todayDateStr', () => {
|
||||
it('returns current date in YYYY-MM-DD format', (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'] });
|
||||
// 2023-10-25 12:00:00 UTC
|
||||
const date = new Date('2023-10-25T12:00:00Z');
|
||||
t.mock.timers.setTime(date.getTime());
|
||||
|
||||
assert.strictEqual(todayDateStr(), '2023-10-25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nowUnix', () => {
|
||||
it('returns current unix timestamp', (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'] });
|
||||
const timeMs = 1700000000000;
|
||||
t.mock.timers.setTime(timeMs);
|
||||
|
||||
// 1700000000000 / 1000 = 1700000000
|
||||
assert.strictEqual(nowUnix(), 1700000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIsoWeekStr', () => {
|
||||
it('returns correct ISO week for simple cases', () => {
|
||||
assert.strictEqual(getIsoWeekStr('2023-01-02'), '2023-W01'); // Mon
|
||||
assert.strictEqual(getIsoWeekStr('2023-01-08'), '2023-W01'); // Sun
|
||||
assert.strictEqual(getIsoWeekStr('2023-01-09'), '2023-W02'); // Mon
|
||||
});
|
||||
|
||||
it('handles year boundaries correctly', () => {
|
||||
// 2023-01-01 is Sunday. Belong to last week of 2022.
|
||||
assert.strictEqual(getIsoWeekStr('2023-01-01'), '2022-W52');
|
||||
// 2024-01-01 is Monday. Week 1 of 2024.
|
||||
assert.strictEqual(getIsoWeekStr('2024-01-01'), '2024-W01');
|
||||
// 2023-12-31 is Sunday. Week 52 of 2023.
|
||||
assert.strictEqual(getIsoWeekStr('2023-12-31'), '2023-W52');
|
||||
});
|
||||
|
||||
it('handles leap years', () => {
|
||||
assert.strictEqual(getIsoWeekStr('2024-02-29'), '2024-W09');
|
||||
});
|
||||
|
||||
it('handles empty input (defaults to today)', () => {
|
||||
// Just check it returns a string in correct format
|
||||
const result = getIsoWeekStr();
|
||||
assert.match(result, /^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
it('handles invalid input gracefully', () => {
|
||||
assert.strictEqual(getIsoWeekStr('invalid-date'), '');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relay Utilities', () => {
|
||||
describe('mergeRelayList', () => {
|
||||
it('merges two lists of relays without duplicates', () => {
|
||||
const primary = ['wss://relay1.com', 'wss://relay2.com'];
|
||||
const fallback = ['wss://relay2.com', 'wss://relay3.com'];
|
||||
const merged = mergeRelayList(primary, fallback);
|
||||
assert.deepStrictEqual(merged, ['wss://relay1.com', 'wss://relay2.com', 'wss://relay3.com']);
|
||||
});
|
||||
|
||||
it('handles empty lists', () => {
|
||||
assert.deepStrictEqual(mergeRelayList([], []), []);
|
||||
assert.deepStrictEqual(mergeRelayList(['wss://relay1.com'], []), ['wss://relay1.com']);
|
||||
assert.deepStrictEqual(mergeRelayList([], ['wss://relay1.com']), ['wss://relay1.com']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Platform Utilities', () => {
|
||||
describe('detectPlatform', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
// Helper to clear relevant env vars
|
||||
const clearEnv = () => {
|
||||
delete process.env.JULES_SESSION_ID;
|
||||
delete process.env.JULES_API_KEY;
|
||||
delete process.env.CODEX_SESSION_ID;
|
||||
delete process.env.CODEX_API_KEY;
|
||||
delete process.env.GOOSE_SESSION_ID;
|
||||
delete process.env.GOOSE_API_KEY;
|
||||
delete process.env.CLAUDE_SESSION_ID;
|
||||
delete process.env.CLAUDE_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
delete process.env.GOOGLE_API_KEY;
|
||||
delete process.env.ANTIGRAVITY_API_KEY;
|
||||
delete process.env.ANTIGRAVITY_SESSION_ID;
|
||||
delete process.env.QWEN_API_KEY;
|
||||
delete process.env.QWEN_SESSION_ID;
|
||||
};
|
||||
|
||||
after(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('detects jules', () => {
|
||||
clearEnv();
|
||||
process.env.JULES_SESSION_ID = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'jules');
|
||||
});
|
||||
|
||||
it('detects codex', () => {
|
||||
clearEnv();
|
||||
process.env.CODEX_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'codex');
|
||||
});
|
||||
|
||||
it('detects goose', () => {
|
||||
clearEnv();
|
||||
process.env.GOOSE_SESSION_ID = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'goose');
|
||||
});
|
||||
|
||||
it('detects claude via CLAUDE_API_KEY', () => {
|
||||
clearEnv();
|
||||
process.env.CLAUDE_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'claude');
|
||||
});
|
||||
|
||||
it('detects claude via ANTHROPIC_API_KEY', () => {
|
||||
clearEnv();
|
||||
process.env.ANTHROPIC_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'claude');
|
||||
});
|
||||
|
||||
it('detects gemini via GEMINI_API_KEY', () => {
|
||||
clearEnv();
|
||||
process.env.GEMINI_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'gemini');
|
||||
});
|
||||
|
||||
it('detects gemini via GOOGLE_API_KEY', () => {
|
||||
clearEnv();
|
||||
process.env.GOOGLE_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'gemini');
|
||||
});
|
||||
|
||||
it('detects antigravity via ANTIGRAVITY_API_KEY', () => {
|
||||
clearEnv();
|
||||
process.env.ANTIGRAVITY_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'antigravity');
|
||||
});
|
||||
|
||||
it('detects antigravity via ANTIGRAVITY_SESSION_ID', () => {
|
||||
clearEnv();
|
||||
process.env.ANTIGRAVITY_SESSION_ID = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'antigravity');
|
||||
});
|
||||
|
||||
it('detects qwen via QWEN_API_KEY', () => {
|
||||
clearEnv();
|
||||
process.env.QWEN_API_KEY = 'test';
|
||||
assert.strictEqual(detectPlatform(), 'qwen');
|
||||
});
|
||||
|
||||
it('returns null if no platform detected', () => {
|
||||
clearEnv();
|
||||
assert.strictEqual(detectPlatform(), null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('String Utilities', () => {
|
||||
describe('relayListLabel', () => {
|
||||
it('joins relay URLs with comma and space', () => {
|
||||
const relays = ['ws://relay1.com', 'ws://relay2.com'];
|
||||
assert.strictEqual(relayListLabel(relays), 'ws://relay1.com, ws://relay2.com');
|
||||
});
|
||||
|
||||
it('handles single relay', () => {
|
||||
const relays = ['ws://relay1.com'];
|
||||
assert.strictEqual(relayListLabel(relays), 'ws://relay1.com');
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const relays = [];
|
||||
assert.strictEqual(relayListLabel(relays), '');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const SCRIPT = path.resolve('scripts/agent/verify-run-artifacts.mjs');
|
||||
|
||||
async function runVerify(cwd, args = []) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SCRIPT, ...args], {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
||||
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function setupFixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'verify-run-artifacts-'));
|
||||
await fs.mkdir(path.join(root, 'src', 'context'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'src', 'todo'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'src', 'decisions'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'src', 'test_logs'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'src', 'prompts', 'daily'), { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'docs', 'agent-handoffs', 'incidents'), { recursive: true });
|
||||
|
||||
await fs.writeFile(path.join(root, 'src', 'prompts', 'daily', 'docs-agent.md'), '# docs-agent\n', 'utf8');
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
async function writeArtifacts(root, { includeMetadata = true, failureId = 'ISSUE-123' } = {}) {
|
||||
const metadata = includeMetadata
|
||||
? ['agent: docs-agent', 'cadence: daily', 'session: session-1', 'run-start: 2026-02-14T00:00:00.000Z', 'prompt-file: src/prompts/daily/docs-agent.md'].join('\n')
|
||||
: '';
|
||||
|
||||
await fs.writeFile(path.join(root, 'src', 'context', 'CONTEXT_1.md'), `${metadata}\n\nGoal: update docs\nScope: scripts\nConstraints: minimal\n`, 'utf8');
|
||||
await fs.writeFile(path.join(root, 'src', 'todo', 'TODO_1.md'), `${metadata}\n\nPending tasks:\n- finish checks\n`, 'utf8');
|
||||
await fs.writeFile(path.join(root, 'src', 'decisions', 'DECISIONS_1.md'), `${metadata}\n\nDecision: keep scope tight\nRationale: minimal risk\n`, 'utf8');
|
||||
await fs.writeFile(path.join(root, 'src', 'test_logs', 'TEST_LOG_1.md'), `${metadata}\n\nCommand: npm test\nResult: failed\nFailure-ID: ${failureId}\n`, 'utf8');
|
||||
}
|
||||
|
||||
test('passes when artifacts include metadata and failure IDs map to unresolved known issue entries', async () => {
|
||||
const root = await setupFixture();
|
||||
await writeArtifacts(root);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'KNOWN_ISSUES.md'),
|
||||
`### [Issue Tracking]\n- **Status:** Open\n- **Issue-ID:** ISSUE-123\n- **Symptom:** reproducible failure\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await runVerify(root, [
|
||||
'--agent', 'docs-agent',
|
||||
'--cadence', 'daily',
|
||||
'--prompt-path', 'src/prompts/daily/docs-agent.md',
|
||||
'--run-start', '2026-02-14T00:00:00.000Z',
|
||||
'--check-failure-notes',
|
||||
]);
|
||||
|
||||
assert.equal(result.code, 0, result.stderr || result.stdout);
|
||||
});
|
||||
|
||||
test('fails when required metadata is missing from artifacts', async () => {
|
||||
const root = await setupFixture();
|
||||
await writeArtifacts(root, { includeMetadata: false });
|
||||
await fs.writeFile(path.join(root, 'KNOWN_ISSUES.md'), '_No active issues currently documented._\n', 'utf8');
|
||||
|
||||
const result = await runVerify(root, [
|
||||
'--agent', 'docs-agent',
|
||||
'--cadence', 'daily',
|
||||
'--prompt-path', 'src/prompts/daily/docs-agent.md',
|
||||
]);
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /missing `agent:` metadata/i);
|
||||
});
|
||||
|
||||
test('fails when failure identifiers are not cross-linked to known issues or incidents', async () => {
|
||||
const root = await setupFixture();
|
||||
await writeArtifacts(root, { includeMetadata: true, failureId: 'ISSUE-999' });
|
||||
await fs.writeFile(
|
||||
path.join(root, 'KNOWN_ISSUES.md'),
|
||||
`### [Issue Tracking]\n- **Status:** Open\n- **Issue-ID:** ISSUE-123\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await runVerify(root, [
|
||||
'--agent', 'docs-agent',
|
||||
'--cadence', 'daily',
|
||||
'--prompt-path', 'src/prompts/daily/docs-agent.md',
|
||||
'--check-failure-notes',
|
||||
]);
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /not cross-linked/i);
|
||||
});
|
||||
|
||||
test('fails when artifact filenames contain Windows-invalid characters', async () => {
|
||||
const root = await setupFixture();
|
||||
await writeArtifacts(root, { includeMetadata: true });
|
||||
await fs.writeFile(path.join(root, 'KNOWN_ISSUES.md'), '_No active issues currently documented._\n', 'utf8');
|
||||
|
||||
const validPath = path.join(root, 'src', 'test_logs', 'TEST_LOG_1.md');
|
||||
const invalidPath = path.join(root, 'src', 'test_logs', 'TEST_LOG_2026-02-15T01:03:31Z.md');
|
||||
const content = await fs.readFile(validPath, 'utf8');
|
||||
await fs.writeFile(invalidPath, content, 'utf8');
|
||||
await fs.unlink(validPath);
|
||||
|
||||
const result = await runVerify(root, [
|
||||
'--agent', 'docs-agent',
|
||||
'--cadence', 'daily',
|
||||
'--prompt-path', 'src/prompts/daily/docs-agent.md',
|
||||
]);
|
||||
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(result.stderr, /invalid filename characters/i);
|
||||
assert.match(result.stderr, /TEST_LOG_2026-02-15T01:03:31Z\.md/);
|
||||
});
|
||||
Reference in new issue
Block a user