mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-31 18:01:55 +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
Binary file not shown.
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { runRelayHealthCheck } from '../../src/relay-health.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
cadence: 'daily',
|
||||
timeoutMs: 6000,
|
||||
allRelaysDownMinutes: 10,
|
||||
minSuccessRate: 0.7,
|
||||
windowMinutes: 60,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const value = argv[i];
|
||||
if (!value.startsWith('--') && !args.cadence) {
|
||||
args.cadence = value;
|
||||
continue;
|
||||
}
|
||||
if (value === '--cadence') {
|
||||
args.cadence = argv[i + 1] || args.cadence;
|
||||
i += 1;
|
||||
} else if (value === '--timeout-ms') {
|
||||
args.timeoutMs = Number.parseInt(argv[i + 1], 10) || args.timeoutMs;
|
||||
i += 1;
|
||||
} else if (value === '--all-relays-down-minutes') {
|
||||
args.allRelaysDownMinutes = Number.parseInt(argv[i + 1], 10) || args.allRelaysDownMinutes;
|
||||
i += 1;
|
||||
} else if (value === '--min-success-rate') {
|
||||
const parsed = Number.parseFloat(argv[i + 1]);
|
||||
args.minSuccessRate = Number.isFinite(parsed) ? parsed : args.minSuccessRate;
|
||||
i += 1;
|
||||
} else if (value === '--window-minutes') {
|
||||
args.windowMinutes = Number.parseInt(argv[i + 1], 10) || args.windowMinutes;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.cadence !== 'daily' && args.cadence !== 'weekly') {
|
||||
console.error('Usage: node scripts/agent/check-relay-health.mjs --cadence <daily|weekly>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await runRelayHealthCheck(args);
|
||||
if (!result.ok) {
|
||||
result.failureCategory = 'all relays unhealthy';
|
||||
}
|
||||
console.log(JSON.stringify(result));
|
||||
process.exit(result.ok ? 0 : 2);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
|
||||
console.log(JSON.stringify({ ok: false, failureCategory: 'health check failed', error: message }));
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
|
||||
const LOG_FILE = 'artifacts/debug_frontend.log';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
|
||||
const logStream = fs.createWriteStream(LOG_FILE, { flags: 'w' });
|
||||
const log = (msg) => {
|
||||
console.log(msg);
|
||||
logStream.write(msg + '\n');
|
||||
};
|
||||
|
||||
log(`[${new Date().toISOString()}] Starting debug session...`);
|
||||
|
||||
page.on('console', msg => {
|
||||
const text = msg.text();
|
||||
const type = msg.type();
|
||||
const output = `[console:${type}] ${text}`;
|
||||
|
||||
if (type === 'error') {
|
||||
log(output);
|
||||
errors.push(output);
|
||||
} else {
|
||||
// Log non-errors only to file to reduce noise, or log everything?
|
||||
// For debugging, everything is useful.
|
||||
log(output);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', err => {
|
||||
const output = `[pageerror] ${err.message}\n${err.stack}`;
|
||||
log(output);
|
||||
errors.push(output);
|
||||
});
|
||||
|
||||
page.on('requestfailed', request => {
|
||||
const failure = request.failure();
|
||||
const errorText = failure ? failure.errorText : 'Unknown error';
|
||||
const output = `[requestfailed] ${request.url()} - ${errorText}`;
|
||||
log(output);
|
||||
errors.push(output);
|
||||
});
|
||||
|
||||
try {
|
||||
log(`Navigating to http://localhost:4173/dashboard/...`);
|
||||
await page.goto('http://localhost:4173/dashboard/', { waitUntil: 'networkidle', timeout: 10000 });
|
||||
log(`Navigation complete.`);
|
||||
// Give it a bit more time for any delayed errors
|
||||
await page.waitForTimeout(2000);
|
||||
} catch (e) {
|
||||
log(`[script:error] Navigation failed or timeout: ${e.message}`);
|
||||
errors.push(`Navigation failed: ${e.message}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
// Don't close stream immediately as logs might still be flushing? No, synchronous write.
|
||||
await new Promise(resolve => logStream.end(resolve));
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`\nFound ${errors.length} errors.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`\nNo errors found.`);
|
||||
process.exit(0);
|
||||
}
|
||||
})();
|
||||
@@ -1,49 +0,0 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
let errorCount = 0;
|
||||
|
||||
page.on('console', msg => {
|
||||
const type = msg.type();
|
||||
if (type === 'error' || type === 'warning') {
|
||||
console.log(`CONSOLE [${type.toUpperCase()}]: ${msg.text()}`);
|
||||
if (type === 'error') errorCount++;
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', exception => {
|
||||
console.log(`PAGE ERROR: ${exception}`);
|
||||
errorCount++;
|
||||
});
|
||||
|
||||
page.on('requestfailed', request => {
|
||||
// Check if failure() is not null before accessing errorText
|
||||
const failure = request.failure();
|
||||
const errorText = failure ? failure.errorText : 'Unknown error';
|
||||
console.log(`REQUEST FAILED: ${request.url()} - ${errorText}`);
|
||||
errorCount++;
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('Navigating to http://localhost:4173/dashboard/ ...');
|
||||
await page.goto('http://localhost:4173/dashboard/');
|
||||
console.log('Page loaded. Waiting 5s for any async errors...');
|
||||
await page.waitForTimeout(5000);
|
||||
} catch (e) {
|
||||
console.error('Navigation failed:', e);
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (errorCount > 0) {
|
||||
console.log(`\nFound ${errorCount} errors.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\nNo errors found.');
|
||||
process.exit(0);
|
||||
}
|
||||
})();
|
||||
@@ -1,140 +0,0 @@
|
||||
import { parseLockEvent } from '../../src/lib.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Seeded random number generator (simple LCG)
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
let t = (a += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const SEED = process.env.SEED ? parseInt(process.env.SEED, 10) : Date.now();
|
||||
const ITERATIONS = 10000;
|
||||
const rand = mulberry32(SEED);
|
||||
|
||||
console.log(`Starting fuzz run. SEED=${SEED}, ITERATIONS=${ITERATIONS}`);
|
||||
|
||||
const REPRO_DIR = 'examples/reproducers/fuzz-lock-event-' + new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
if (!fs.existsSync(REPRO_DIR)) {
|
||||
fs.mkdirSync(REPRO_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function randomString(length) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(rand() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function randomJson() {
|
||||
const types = ['object', 'array', 'string', 'number', 'boolean', 'null'];
|
||||
const type = types[Math.floor(rand() * types.length)];
|
||||
|
||||
if (type === 'object') {
|
||||
const obj = {};
|
||||
const keys = Math.floor(rand() * 5);
|
||||
for (let i = 0; i < keys; i++) {
|
||||
obj[randomString(5)] = randomJson();
|
||||
}
|
||||
return obj;
|
||||
} else if (type === 'array') {
|
||||
const arr = [];
|
||||
const len = Math.floor(rand() * 5);
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr.push(randomJson());
|
||||
}
|
||||
return arr;
|
||||
} else if (type === 'string') {
|
||||
return randomString(10);
|
||||
} else if (type === 'number') {
|
||||
return rand() * 1000;
|
||||
} else if (type === 'boolean') {
|
||||
return rand() > 0.5;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateEvent() {
|
||||
const malformed = rand() > 0.8;
|
||||
const content = malformed ? randomString(100) : JSON.stringify(randomJson());
|
||||
|
||||
const tags = [];
|
||||
const numTags = Math.floor(rand() * 5);
|
||||
for (let i = 0; i < numTags; i++) {
|
||||
const tagLen = Math.floor(rand() * 3) + 1;
|
||||
const tag = [];
|
||||
for (let j = 0; j < tagLen; j++) {
|
||||
tag.push(randomString(10));
|
||||
}
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
// Sometimes add specific tags
|
||||
if (rand() > 0.5) {
|
||||
tags.push(['d', randomString(20)]);
|
||||
}
|
||||
if (rand() > 0.5) {
|
||||
tags.push(['expiration', String(Math.floor(Date.now() / 1000) + 1000)]);
|
||||
}
|
||||
|
||||
return {
|
||||
id: randomString(64), // Mock ID
|
||||
pubkey: randomString(64), // Mock Pubkey
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: tags,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const event = generateEvent();
|
||||
|
||||
try {
|
||||
parseLockEvent(event);
|
||||
} catch (err) {
|
||||
failures++;
|
||||
console.error(`Failure #${failures} at iteration ${i}: ${err.message}`);
|
||||
|
||||
const reproFile = path.join(REPRO_DIR, `repro-${i}.json`);
|
||||
fs.writeFileSync(reproFile, JSON.stringify(event, null, 2));
|
||||
|
||||
const reproScript = path.join(REPRO_DIR, `run-repro-${i}.mjs`);
|
||||
const scriptContent = `
|
||||
import { parseLockEvent } from '../../../src/lib.mjs';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const event = JSON.parse(fs.readFileSync('repro-${i}.json', 'utf8'));
|
||||
console.log('Running repro case...');
|
||||
try {
|
||||
parseLockEvent(event);
|
||||
console.log('Success (no crash)');
|
||||
} catch (err) {
|
||||
console.error('Crash reproduced:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(reproScript, scriptContent);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures === 0) {
|
||||
console.log('Fuzzing completed successfully. No failures found.');
|
||||
// Clean up empty directory if no failures
|
||||
try {
|
||||
fs.rmdirSync(REPRO_DIR);
|
||||
} catch {
|
||||
// Ignore error if directory is not empty or missing
|
||||
}
|
||||
} else {
|
||||
console.log(`Fuzzing completed with ${failures} failures.`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,640 +0,0 @@
|
||||
import WebSocket from 'ws';
|
||||
import { Relay, useWebSocketImplementation } from 'nostr-tools/relay';
|
||||
import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { KIND_APP_DATA, DEFAULT_RELAYS } from '../../src/constants.mjs';
|
||||
import { ensureDir } from '../../src/utils.mjs';
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const KIND_VIEW_EVENT = 1;
|
||||
const KNOWN_PUBLIC_RELAY_HOSTS = new Set(DEFAULT_RELAYS.map((relay) => new URL(relay).hostname));
|
||||
const RELAY_SAFETY_ACK_VALUE = 'LOAD_TEST_INFRA_CONFIRMED';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const raw = argv[i];
|
||||
if (!raw.startsWith('--')) continue;
|
||||
const token = raw.slice(2);
|
||||
if (!token) continue;
|
||||
const eqIndex = token.indexOf('=');
|
||||
if (eqIndex >= 0) {
|
||||
args[token.slice(0, eqIndex)] = token.slice(eqIndex + 1);
|
||||
continue;
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
args[token] = next;
|
||||
i += 1;
|
||||
} else {
|
||||
args[token] = '1';
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function getConfigValue(args, key, envKey, fallback) {
|
||||
if (args[key] !== undefined) return args[key];
|
||||
if (process.env[envKey] !== undefined) return process.env[envKey];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseIntStrict(value, name) {
|
||||
if (!/^-?\d+$/.test(String(value))) {
|
||||
throw new Error(`${name} must be an integer (got "${value}")`);
|
||||
}
|
||||
return Number.parseInt(value, 10);
|
||||
}
|
||||
|
||||
function parseFloatStrict(value, name) {
|
||||
if (!/^[-+]?\d+(\.\d+)?$/.test(String(value))) {
|
||||
throw new Error(`${name} must be a number (got "${value}")`);
|
||||
}
|
||||
return Number.parseFloat(value);
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function assertRange(value, name, min, max) {
|
||||
if (value < min || value > max) {
|
||||
throw new Error(`${name} out of range (${min}..${max}): ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateIpv4(hostname) {
|
||||
const match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (!match) return false;
|
||||
const parts = match.slice(1).map((part) => Number.parseInt(part, 10));
|
||||
if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) return false;
|
||||
if (parts[0] === 10) return true;
|
||||
if (parts[0] === 127) return true;
|
||||
if (parts[0] === 192 && parts[1] === 168) return true;
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] === 169 && parts[1] === 254) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalOrPrivateHost(hostname) {
|
||||
const lower = hostname.toLowerCase();
|
||||
return lower === 'localhost'
|
||||
|| lower === '::1'
|
||||
|| lower.endsWith('.local')
|
||||
|| isPrivateIpv4(lower);
|
||||
}
|
||||
|
||||
function classifyError(error) {
|
||||
const text = String(error?.message || error || '').toLowerCase();
|
||||
if (text.includes('timeout')) return 'timeout';
|
||||
if (text.includes('reject') || text.includes('invalid signature') || text.includes('validation')) return 'reject';
|
||||
if (text.includes('closed') || text.includes('disconnect') || text.includes('econnreset')) return 'disconnect';
|
||||
if (text.includes('refused') || text.includes('enotfound') || text.includes('etimedout')) return 'network';
|
||||
return 'publish_error';
|
||||
}
|
||||
|
||||
function xmur3(seed) {
|
||||
let h = 1779033703 ^ seed.length;
|
||||
for (let i = 0; i < seed.length; i += 1) {
|
||||
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||
h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
};
|
||||
}
|
||||
|
||||
function mulberry32(seedInt) {
|
||||
let seed = seedInt >>> 0;
|
||||
return () => {
|
||||
seed += 0x6d2b79f5;
|
||||
let t = seed;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(samples, p) {
|
||||
if (samples.length === 0) return 0;
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
function summarizePercentiles(samples) {
|
||||
if (samples.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
minMs: 0,
|
||||
avgMs: 0,
|
||||
p50Ms: 0,
|
||||
p90Ms: 0,
|
||||
p95Ms: 0,
|
||||
p99Ms: 0,
|
||||
maxMs: 0,
|
||||
};
|
||||
}
|
||||
const minMs = samples.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
|
||||
const maxMs = samples.reduce((max, value) => Math.max(max, value), 0);
|
||||
const sumMs = samples.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
count: samples.length,
|
||||
minMs,
|
||||
avgMs: sumMs / samples.length,
|
||||
p50Ms: percentile(samples, 50),
|
||||
p90Ms: percentile(samples, 90),
|
||||
p95Ms: percentile(samples, 95),
|
||||
p99Ms: percentile(samples, 99),
|
||||
maxMs,
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs) {
|
||||
let timeoutHandle = null;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error(`publish timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
});
|
||||
}
|
||||
|
||||
function buildViewEvent(secretKey, nowMs) {
|
||||
return finalizeEvent({
|
||||
kind: KIND_VIEW_EVENT,
|
||||
created_at: Math.floor(nowMs / 1000),
|
||||
tags: [
|
||||
['t', 'torch-load-test'],
|
||||
['t', 'view-event'],
|
||||
],
|
||||
content: JSON.stringify({
|
||||
type: 'view_event',
|
||||
source: 'load-test-harness',
|
||||
viewed_at: new Date(nowMs).toISOString(),
|
||||
}),
|
||||
}, secretKey);
|
||||
}
|
||||
|
||||
function buildMultipartMetadataEvent(secretKey, nowMs, rng, sequence, partsPerEvent) {
|
||||
const videoId = `video-${Math.floor(rng() * 1000000).toString(16)}-${sequence}`;
|
||||
const part = 1 + Math.floor(rng() * partsPerEvent);
|
||||
const content = {
|
||||
type: 'video_metadata_multipart',
|
||||
source: 'load-test-harness',
|
||||
schema_basis: 'nostr-app-data-kind-30078',
|
||||
video_id: videoId,
|
||||
part,
|
||||
total_parts: partsPerEvent,
|
||||
payload: {
|
||||
title: `Load test clip ${sequence}`,
|
||||
duration_sec: 30 + Math.floor(rng() * 600),
|
||||
codecs: ['h264', 'aac'],
|
||||
chunk_manifest: Array.from({ length: partsPerEvent }, (_, idx) => ({
|
||||
part: idx + 1,
|
||||
bytes: 500 + Math.floor(rng() * 2500),
|
||||
})),
|
||||
},
|
||||
};
|
||||
return finalizeEvent({
|
||||
kind: KIND_APP_DATA,
|
||||
created_at: Math.floor(nowMs / 1000),
|
||||
tags: [
|
||||
['t', 'torch-load-test'],
|
||||
['t', 'video-metadata-multipart'],
|
||||
['d', `${videoId}-${part}`],
|
||||
],
|
||||
content: JSON.stringify(content),
|
||||
}, secretKey);
|
||||
}
|
||||
|
||||
function summarizeRelay(relayUrl) {
|
||||
const parsed = new URL(relayUrl);
|
||||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
|
||||
}
|
||||
|
||||
function redactRelayForReport(relayUrl) {
|
||||
const parsed = new URL(relayUrl);
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || '(default)',
|
||||
};
|
||||
}
|
||||
|
||||
function buildRemediation(report) {
|
||||
const items = [];
|
||||
const { results } = report;
|
||||
if (results.backpressureDrops > 0) {
|
||||
items.push('Reduce RATE_EPS or increase MAX_INFLIGHT only after confirming relay capacity; backpressure drops indicate local pipeline saturation.');
|
||||
}
|
||||
if ((results.errorBreakdown.timeout || 0) > 0) {
|
||||
items.push('Investigate relay write acknowledgements and tune PUBLISH_TIMEOUT_MS; timeout errors were observed during publish acknowledgements.');
|
||||
}
|
||||
if ((results.errorBreakdown.disconnect || 0) > 0 || (results.errorBreakdown.network || 0) > 0) {
|
||||
items.push('Check relay network stability (TCP resets, DNS, proxy paths) before raising client count.');
|
||||
}
|
||||
if (results.latency.p99Ms > 500) {
|
||||
items.push('Profile relay-side event acceptance path; p99 publish latency exceeded 500ms.');
|
||||
}
|
||||
if (results.eventLoopLag.p95Ms > 50) {
|
||||
items.push('Reduce local scheduling pressure (lower RATE_EPS or CLIENTS) to keep event loop lag under 50ms.');
|
||||
}
|
||||
if (results.resource.maxRssMb > 512) {
|
||||
items.push('Cap MAX_CONNECTIONS or lower CLIENTS to reduce harness memory pressure (RSS exceeded 512MB).');
|
||||
}
|
||||
if (items.length === 0) {
|
||||
items.push('No significant bottlenecks observed in this run; increase load gradually and repeat to find saturation point.');
|
||||
}
|
||||
return items.slice(0, 10);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const relayUrlRaw = getConfigValue(args, 'relay-url', 'RELAY_URL', '');
|
||||
const relayUrl = String(relayUrlRaw || '').trim();
|
||||
if (!relayUrl) throw new Error('RELAY_URL (or --relay-url) is required.');
|
||||
|
||||
const parsedRelay = new URL(relayUrl);
|
||||
if (!['ws:', 'wss:'].includes(parsedRelay.protocol)) {
|
||||
throw new Error(`Unsupported relay protocol ${parsedRelay.protocol}. Use ws:// or wss://.`);
|
||||
}
|
||||
|
||||
const allowDedicated = parseBoolean(getConfigValue(args, 'allow-dedicated-test-relay', 'ALLOW_DEDICATED_TEST_RELAY', '0'), false);
|
||||
const relaySafetyAck = String(getConfigValue(args, 'relay-safety-ack', 'RELAY_SAFETY_ACK', '') || '').trim();
|
||||
const host = parsedRelay.hostname.toLowerCase();
|
||||
|
||||
if (KNOWN_PUBLIC_RELAY_HOSTS.has(host)) {
|
||||
throw new Error(`Refusing to target known public relay host: ${host}`);
|
||||
}
|
||||
|
||||
if (!isLocalOrPrivateHost(host)) {
|
||||
if (!allowDedicated || relaySafetyAck !== RELAY_SAFETY_ACK_VALUE) {
|
||||
throw new Error(
|
||||
`Refusing non-local relay host "${host}". Set ALLOW_DEDICATED_TEST_RELAY=1 and RELAY_SAFETY_ACK=${RELAY_SAFETY_ACK_VALUE} for dedicated test infrastructure.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const clients = parseIntStrict(getConfigValue(args, 'clients', 'CLIENTS', '1000'), 'CLIENTS');
|
||||
const durationSec = parseIntStrict(getConfigValue(args, 'duration-sec', 'DURATION_SEC', '600'), 'DURATION_SEC');
|
||||
const rateEps = parseFloatStrict(getConfigValue(args, 'rate-eps', 'RATE_EPS', '20'), 'RATE_EPS');
|
||||
const mix = parseFloatStrict(getConfigValue(args, 'mix', 'MIX', '0.2'), 'MIX');
|
||||
const seed = String(getConfigValue(args, 'seed', 'SEED', '') || '').trim();
|
||||
const dryRun = parseBoolean(getConfigValue(args, 'dry-run', 'DRY_RUN', '1'), true);
|
||||
const connectConcurrency = parseIntStrict(getConfigValue(args, 'connect-concurrency', 'CONNECT_CONCURRENCY', '25'), 'CONNECT_CONCURRENCY');
|
||||
const maxConnections = parseIntStrict(getConfigValue(args, 'max-connections', 'MAX_CONNECTIONS', String(Math.min(clients, 250))), 'MAX_CONNECTIONS');
|
||||
const maxInflight = parseIntStrict(getConfigValue(args, 'max-inflight', 'MAX_INFLIGHT', '300'), 'MAX_INFLIGHT');
|
||||
const publishTimeoutMs = parseIntStrict(getConfigValue(args, 'publish-timeout-ms', 'PUBLISH_TIMEOUT_MS', '15000'), 'PUBLISH_TIMEOUT_MS');
|
||||
const resourceSampleMs = parseIntStrict(getConfigValue(args, 'resource-sample-ms', 'RESOURCE_SAMPLE_MS', '5000'), 'RESOURCE_SAMPLE_MS');
|
||||
const eventLoopSampleMs = parseIntStrict(getConfigValue(args, 'event-loop-sample-ms', 'EVENT_LOOP_SAMPLE_MS', '200'), 'EVENT_LOOP_SAMPLE_MS');
|
||||
const metadataParts = parseIntStrict(getConfigValue(args, 'metadata-parts', 'METADATA_PARTS', '4'), 'METADATA_PARTS');
|
||||
|
||||
assertRange(clients, 'CLIENTS', 1, 100000);
|
||||
assertRange(durationSec, 'DURATION_SEC', 1, 86400);
|
||||
assertRange(rateEps, 'RATE_EPS', 0.1, 100000);
|
||||
assertRange(mix, 'MIX', 0, 1);
|
||||
assertRange(connectConcurrency, 'CONNECT_CONCURRENCY', 1, 1000);
|
||||
assertRange(maxConnections, 'MAX_CONNECTIONS', 1, clients);
|
||||
assertRange(maxInflight, 'MAX_INFLIGHT', 1, 100000);
|
||||
assertRange(publishTimeoutMs, 'PUBLISH_TIMEOUT_MS', 100, 120000);
|
||||
assertRange(resourceSampleMs, 'RESOURCE_SAMPLE_MS', 500, 120000);
|
||||
assertRange(eventLoopSampleMs, 'EVENT_LOOP_SAMPLE_MS', 20, 2000);
|
||||
assertRange(metadataParts, 'METADATA_PARTS', 2, 20);
|
||||
|
||||
const rng = seed ? mulberry32(xmur3(seed)()) : Math.random;
|
||||
const reportDir = path.resolve(process.cwd(), 'reports', 'load-test');
|
||||
ensureDir(reportDir);
|
||||
|
||||
const relaySummary = summarizeRelay(relayUrl);
|
||||
console.log('Load test configuration');
|
||||
console.log(` relay: ${relaySummary}`);
|
||||
console.log(` clients: ${clients}`);
|
||||
console.log(` durationSec: ${durationSec}`);
|
||||
console.log(` rateEps: ${rateEps}`);
|
||||
console.log(` mix(metadata): ${mix}`);
|
||||
console.log(` seed: ${seed || '(random)'}`);
|
||||
console.log(` dryRun: ${dryRun}`);
|
||||
console.log(` maxConnections: ${maxConnections}`);
|
||||
console.log(` connectConcurrency: ${connectConcurrency}`);
|
||||
console.log(` maxInflight: ${maxInflight}`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log('DRY_RUN enabled: no relay connections or publish calls will be made.');
|
||||
}
|
||||
|
||||
const clientKeys = Array.from({ length: clients }, () => generateSecretKey());
|
||||
|
||||
const stats = {
|
||||
startedAtIso: new Date().toISOString(),
|
||||
startedAtMs: Date.now(),
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
backpressureDrops: 0,
|
||||
inFlightPeak: 0,
|
||||
latenciesMs: [],
|
||||
errorBreakdown: {},
|
||||
stackSamples: {},
|
||||
throughputBySecond: {},
|
||||
resourceSnapshots: [],
|
||||
eventLoopLagMs: [],
|
||||
mode: dryRun ? 'dry-run' : 'live',
|
||||
};
|
||||
|
||||
const cpuStart = process.cpuUsage();
|
||||
let previousCpu = process.cpuUsage();
|
||||
let previousCpuWallMs = Date.now();
|
||||
|
||||
const eventLoopTimer = setInterval(() => {
|
||||
const expected = Date.now() + eventLoopSampleMs;
|
||||
setTimeout(() => {
|
||||
const lag = Math.max(0, Date.now() - expected);
|
||||
stats.eventLoopLagMs.push(lag);
|
||||
}, eventLoopSampleMs);
|
||||
}, eventLoopSampleMs);
|
||||
|
||||
const resourceTimer = setInterval(() => {
|
||||
const nowMs = Date.now();
|
||||
const mem = process.memoryUsage();
|
||||
const cpuDiff = process.cpuUsage(previousCpu);
|
||||
const wallMs = Math.max(1, nowMs - previousCpuWallMs);
|
||||
const cpuMs = (cpuDiff.user + cpuDiff.system) / 1000;
|
||||
const cpuPercent = Number(((cpuMs / wallMs) * 100).toFixed(2));
|
||||
previousCpu = process.cpuUsage();
|
||||
previousCpuWallMs = nowMs;
|
||||
stats.resourceSnapshots.push({
|
||||
tSec: Math.floor((nowMs - stats.startedAtMs) / 1000),
|
||||
rssMb: Number((mem.rss / (1024 * 1024)).toFixed(2)),
|
||||
heapUsedMb: Number((mem.heapUsed / (1024 * 1024)).toFixed(2)),
|
||||
heapTotalMb: Number((mem.heapTotal / (1024 * 1024)).toFixed(2)),
|
||||
cpuPercent,
|
||||
});
|
||||
}, resourceSampleMs);
|
||||
|
||||
const recordAttempt = () => {
|
||||
const sec = Math.floor((Date.now() - stats.startedAtMs) / 1000);
|
||||
const bucket = stats.throughputBySecond[sec] || { attempted: 0, succeeded: 0, failed: 0, backpressureDrops: 0 };
|
||||
bucket.attempted += 1;
|
||||
stats.throughputBySecond[sec] = bucket;
|
||||
};
|
||||
|
||||
const recordResult = (kind) => {
|
||||
const sec = Math.floor((Date.now() - stats.startedAtMs) / 1000);
|
||||
const bucket = stats.throughputBySecond[sec] || { attempted: 0, succeeded: 0, failed: 0, backpressureDrops: 0 };
|
||||
bucket[kind] += 1;
|
||||
stats.throughputBySecond[sec] = bucket;
|
||||
};
|
||||
|
||||
const relays = [];
|
||||
if (!dryRun) {
|
||||
let nextConnectionIndex = 0;
|
||||
const connectionErrors = [];
|
||||
const workers = Array.from({ length: Math.min(connectConcurrency, maxConnections) }, () => (async () => {
|
||||
while (nextConnectionIndex < maxConnections) {
|
||||
const current = nextConnectionIndex;
|
||||
nextConnectionIndex += 1;
|
||||
try {
|
||||
const relay = await Relay.connect(relayUrl);
|
||||
relays[current] = relay;
|
||||
} catch (error) {
|
||||
connectionErrors.push(String(error?.message || error));
|
||||
}
|
||||
}
|
||||
})());
|
||||
|
||||
await Promise.all(workers);
|
||||
if (!relays.length) {
|
||||
throw new Error(`Failed to establish any relay connections. errors=${connectionErrors.slice(0, 3).join(' | ') || '(none)'}`);
|
||||
}
|
||||
if (connectionErrors.length) {
|
||||
stats.errorBreakdown.connection = connectionErrors.length;
|
||||
stats.stackSamples.connection = connectionErrors.slice(0, 3);
|
||||
}
|
||||
console.log(`Established ${relays.length}/${maxConnections} relay connections.`);
|
||||
}
|
||||
|
||||
const publishContext = {
|
||||
inFlight: 0,
|
||||
tasks: new Set(),
|
||||
metadataSeq: 0,
|
||||
};
|
||||
|
||||
async function publishOne(clientIndex, isMetadata) {
|
||||
const nowMs = Date.now();
|
||||
const secretKey = clientKeys[clientIndex];
|
||||
const event = isMetadata
|
||||
? buildMultipartMetadataEvent(secretKey, nowMs, rng, publishContext.metadataSeq += 1, metadataParts)
|
||||
: buildViewEvent(secretKey, nowMs);
|
||||
|
||||
const started = performance.now();
|
||||
try {
|
||||
if (dryRun) {
|
||||
const syntheticLatency = 5 + Math.floor(rng() * 20);
|
||||
await sleep(syntheticLatency);
|
||||
} else {
|
||||
const relay = relays[clientIndex % relays.length];
|
||||
await withTimeout(relay.publish(event), publishTimeoutMs);
|
||||
}
|
||||
const latencyMs = Number((performance.now() - started).toFixed(3));
|
||||
stats.succeeded += 1;
|
||||
stats.latenciesMs.push(latencyMs);
|
||||
recordResult('succeeded');
|
||||
} catch (error) {
|
||||
stats.failed += 1;
|
||||
const category = classifyError(error);
|
||||
stats.errorBreakdown[category] = (stats.errorBreakdown[category] || 0) + 1;
|
||||
const stack = String(error?.stack || error?.message || error);
|
||||
const trimmed = stack.split('\n').slice(0, 3).join('\n');
|
||||
if (!stats.stackSamples[category]) stats.stackSamples[category] = [];
|
||||
if (stats.stackSamples[category].length < 3) {
|
||||
stats.stackSamples[category].push(trimmed);
|
||||
}
|
||||
recordResult('failed');
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = durationSec * 1000;
|
||||
const endAt = stats.startedAtMs + durationMs;
|
||||
const paceMs = 1000 / rateEps;
|
||||
let nextTick = Date.now();
|
||||
|
||||
while (Date.now() < endAt) {
|
||||
if (Date.now() < nextTick) {
|
||||
await sleep(Math.max(0, nextTick - Date.now()));
|
||||
continue;
|
||||
}
|
||||
nextTick += paceMs;
|
||||
|
||||
stats.attempted += 1;
|
||||
recordAttempt();
|
||||
if (publishContext.inFlight >= maxInflight) {
|
||||
stats.backpressureDrops += 1;
|
||||
stats.failed += 1;
|
||||
stats.errorBreakdown.backpressure = (stats.errorBreakdown.backpressure || 0) + 1;
|
||||
recordResult('failed');
|
||||
recordResult('backpressureDrops');
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientIndex = Math.floor(rng() * clients);
|
||||
const isMetadata = rng() < mix;
|
||||
publishContext.inFlight += 1;
|
||||
stats.inFlightPeak = Math.max(stats.inFlightPeak, publishContext.inFlight);
|
||||
const task = publishOne(clientIndex, isMetadata).finally(() => {
|
||||
publishContext.inFlight -= 1;
|
||||
publishContext.tasks.delete(task);
|
||||
});
|
||||
publishContext.tasks.add(task);
|
||||
}
|
||||
|
||||
await Promise.allSettled([...publishContext.tasks]);
|
||||
|
||||
clearInterval(eventLoopTimer);
|
||||
clearInterval(resourceTimer);
|
||||
for (const relay of relays) relay.close();
|
||||
|
||||
const completedAtMs = Date.now();
|
||||
const completedAtIso = new Date(completedAtMs).toISOString();
|
||||
const cpuTotal = process.cpuUsage(cpuStart);
|
||||
const runDurationSec = Math.max(0.001, (completedAtMs - stats.startedAtMs) / 1000);
|
||||
const latency = summarizePercentiles(stats.latenciesMs);
|
||||
const eventLoopLag = summarizePercentiles(stats.eventLoopLagMs);
|
||||
const maxRssMb = stats.resourceSnapshots.reduce((max, item) => Math.max(max, item.rssMb), 0);
|
||||
const maxHeapMb = stats.resourceSnapshots.reduce((max, item) => Math.max(max, item.heapUsedMb), 0);
|
||||
const avgCpuPercent = stats.resourceSnapshots.length
|
||||
? stats.resourceSnapshots.reduce((sum, item) => sum + item.cpuPercent, 0) / stats.resourceSnapshots.length
|
||||
: 0;
|
||||
|
||||
const throughputSeries = Object.entries(stats.throughputBySecond)
|
||||
.map(([second, values]) => ({ second: Number(second), ...values }))
|
||||
.sort((a, b) => a.second - b.second);
|
||||
|
||||
const report = {
|
||||
generatedAt: completedAtIso,
|
||||
config: {
|
||||
relay: redactRelayForReport(relayUrl),
|
||||
relaySummary,
|
||||
dryRun,
|
||||
clients,
|
||||
durationSec,
|
||||
rateEps,
|
||||
mixMetadata: mix,
|
||||
seed: seed || null,
|
||||
connectConcurrency,
|
||||
maxConnections,
|
||||
maxInflight,
|
||||
publishTimeoutMs,
|
||||
metadataParts,
|
||||
},
|
||||
results: {
|
||||
attempted: stats.attempted,
|
||||
succeeded: stats.succeeded,
|
||||
failed: stats.failed,
|
||||
successRate: Number((stats.succeeded / Math.max(1, stats.attempted)).toFixed(4)),
|
||||
throughputEps: Number((stats.succeeded / runDurationSec).toFixed(3)),
|
||||
backpressureDrops: stats.backpressureDrops,
|
||||
inFlightPeak: stats.inFlightPeak,
|
||||
latency,
|
||||
eventLoopLag: {
|
||||
avgMs: Number(eventLoopLag.avgMs.toFixed(3)),
|
||||
p95Ms: Number(eventLoopLag.p95Ms.toFixed(3)),
|
||||
},
|
||||
errorBreakdown: stats.errorBreakdown,
|
||||
topStackTraces: stats.stackSamples,
|
||||
resource: {
|
||||
samples: stats.resourceSnapshots,
|
||||
sampleCount: stats.resourceSnapshots.length,
|
||||
maxRssMb,
|
||||
maxHeapUsedMb: maxHeapMb,
|
||||
avgCpuPercent: Number(avgCpuPercent.toFixed(2)),
|
||||
cpuUserMs: Number((cpuTotal.user / 1000).toFixed(3)),
|
||||
cpuSystemMs: Number((cpuTotal.system / 1000).toFixed(3)),
|
||||
},
|
||||
throughputSeries,
|
||||
hotFunctions: 'not measured',
|
||||
startedAtIso: stats.startedAtIso,
|
||||
completedAtIso,
|
||||
runDurationSec: Number(runDurationSec.toFixed(3)),
|
||||
},
|
||||
};
|
||||
|
||||
report.remediation = buildRemediation(report);
|
||||
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const jsonPath = path.join(reportDir, `load-report-${dateStr}.json`);
|
||||
const markdownPath = path.join(reportDir, `load-test-report-${dateStr}.md`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
||||
|
||||
const markdown = [
|
||||
`# Load Test Report - ${dateStr}`,
|
||||
'',
|
||||
'## Safety',
|
||||
'- Does not target public relays.',
|
||||
'- Requires explicit `RELAY_URL`.',
|
||||
`- Relay target: ${relaySummary}`,
|
||||
`- Mode: ${dryRun ? 'dry-run (no network sends)' : 'live publish test'}`,
|
||||
'',
|
||||
'## Configuration',
|
||||
`- Clients: ${clients}`,
|
||||
`- Duration: ${durationSec}s`,
|
||||
`- Rate: ${rateEps} events/sec`,
|
||||
`- Metadata mix: ${mix}`,
|
||||
`- Max connections: ${maxConnections}`,
|
||||
`- Max in-flight publishes: ${maxInflight}`,
|
||||
`- Seed: ${seed || '(random)'}`,
|
||||
'',
|
||||
'## Summary',
|
||||
`- Attempted: ${report.results.attempted}`,
|
||||
`- Succeeded: ${report.results.succeeded}`,
|
||||
`- Failed: ${report.results.failed}`,
|
||||
`- Throughput: ${report.results.throughputEps} events/sec`,
|
||||
`- Success rate: ${(report.results.successRate * 100).toFixed(2)}%`,
|
||||
'',
|
||||
'## Latency',
|
||||
`- p50: ${report.results.latency.p50Ms.toFixed(3)} ms`,
|
||||
`- p90: ${report.results.latency.p90Ms.toFixed(3)} ms`,
|
||||
`- p95: ${report.results.latency.p95Ms.toFixed(3)} ms`,
|
||||
`- p99: ${report.results.latency.p99Ms.toFixed(3)} ms`,
|
||||
'',
|
||||
'## Event Loop Lag',
|
||||
`- Avg: ${report.results.eventLoopLag.avgMs} ms`,
|
||||
`- p95: ${report.results.eventLoopLag.p95Ms} ms`,
|
||||
'',
|
||||
'## Error Taxonomy',
|
||||
`- ${Object.entries(report.results.errorBreakdown).map(([key, value]) => `${key}: ${value}`).join(', ') || 'none'}`,
|
||||
'',
|
||||
'## Hot Functions',
|
||||
'- not measured',
|
||||
'',
|
||||
'## Prioritized Remediation',
|
||||
...report.remediation.map((item, index) => `${index + 1}. ${item}`),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(markdownPath, markdown);
|
||||
|
||||
console.log(`JSON report written: ${jsonPath}`);
|
||||
console.log(`Markdown report written: ${markdownPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Load test harness failed: ${error?.stack || error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(__dirname, '../../');
|
||||
const DAILY_DIR = path.join(ROOT_DIR, 'src/prompts/daily');
|
||||
const WEEKLY_DIR = path.join(ROOT_DIR, 'src/prompts/weekly');
|
||||
const REPORT_DIR = path.join(ROOT_DIR, 'reports/prompt-safety');
|
||||
const MEMORY_DIR = path.join(ROOT_DIR, 'memory-updates');
|
||||
|
||||
if (!fs.existsSync(REPORT_DIR)) {
|
||||
fs.mkdirSync(REPORT_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(MEMORY_DIR)) {
|
||||
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const SAFETY_KEYWORDS = [
|
||||
'FAILURE MODES',
|
||||
'EXIT CRITERIA',
|
||||
'SKIP',
|
||||
'NO-OP',
|
||||
'STOP IF',
|
||||
'DO NOTHING',
|
||||
'NO ACTION'
|
||||
];
|
||||
|
||||
const FORCEFUL_KEYWORDS = [
|
||||
'MUST',
|
||||
'ALWAYS',
|
||||
'FORCE',
|
||||
'REQUIRED'
|
||||
];
|
||||
|
||||
function analyzeFile(filepath) {
|
||||
const content = fs.readFileSync(filepath, 'utf8');
|
||||
const upperContent = content.toUpperCase();
|
||||
const filename = path.basename(filepath);
|
||||
|
||||
const hasSafetySection = SAFETY_KEYWORDS.some(k => upperContent.includes(k));
|
||||
|
||||
// Simple heuristic for forceful language without mitigation
|
||||
// This is a naive check and might have false positives
|
||||
let forcefulCount = 0;
|
||||
FORCEFUL_KEYWORDS.forEach(k => {
|
||||
const regex = new RegExp(`\\b${k}\\b`, 'g');
|
||||
const matches = (upperContent.match(regex) || []).length;
|
||||
forcefulCount += matches;
|
||||
});
|
||||
|
||||
return {
|
||||
filename,
|
||||
hasSafetySection,
|
||||
forcefulCount,
|
||||
status: hasSafetySection ? 'SAFE' : 'NEEDS_REVIEW'
|
||||
};
|
||||
}
|
||||
|
||||
function runAudit() {
|
||||
const files = [
|
||||
...fs.readdirSync(DAILY_DIR).map(f => path.join(DAILY_DIR, f)),
|
||||
...fs.readdirSync(WEEKLY_DIR).map(f => path.join(WEEKLY_DIR, f))
|
||||
].filter(f => f.endsWith('.md'));
|
||||
|
||||
const results = files.map(analyzeFile);
|
||||
|
||||
const safeCount = results.filter(r => r.status === 'SAFE').length;
|
||||
const needsReviewCount = results.filter(r => r.status === 'NEEDS_REVIEW').length;
|
||||
const totalCount = results.length;
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
||||
const reportFile = path.join(REPORT_DIR, `audit-${timestamp}.md`);
|
||||
|
||||
let reportContent = `# Prompt Safety Audit Report - ${timestamp}\n\n`;
|
||||
reportContent += `**Total Prompts:** ${totalCount}\n`;
|
||||
reportContent += `**Safe:** ${safeCount}\n`;
|
||||
reportContent += `**Needs Review:** ${needsReviewCount}\n\n`;
|
||||
|
||||
reportContent += `## Findings\n\n`;
|
||||
reportContent += `| Filename | Status | Has Safety Section | Forceful Keywords (Approx) |\n`;
|
||||
reportContent += `|---|---|---|---|\n`;
|
||||
|
||||
results.forEach(r => {
|
||||
reportContent += `| ${r.filename} | ${r.status} | ${r.hasSafetySection ? 'Yes' : 'No'} | ${r.forcefulCount} |\n`;
|
||||
});
|
||||
|
||||
reportContent += `\n## Recommendations\n`;
|
||||
reportContent += `- Review prompts marked 'NEEDS_REVIEW'.\n`;
|
||||
reportContent += `- Ensure all prompts have a 'FAILURE MODES' or 'EXIT CRITERIA' section.\n`;
|
||||
|
||||
fs.writeFileSync(reportFile, reportContent);
|
||||
console.log(`Report written to ${reportFile}`);
|
||||
|
||||
// Memory Update
|
||||
let memoryContent = `Goal: Audit prompt safety.\n`;
|
||||
memoryContent += `Result: Audited ${totalCount} prompts. ${safeCount} safe, ${needsReviewCount} need review.\n`;
|
||||
if (needsReviewCount > 0) {
|
||||
memoryContent += `Flagged prompts: ${results.filter(r => r.status === 'NEEDS_REVIEW').map(r => r.filename).join(', ')}\n`;
|
||||
}
|
||||
|
||||
const memoryFile = path.join(MEMORY_DIR, `${timestamp}__prompt-safety-audit-agent.md`);
|
||||
fs.writeFileSync(memoryFile, memoryContent);
|
||||
console.log(`Memory update written to ${memoryFile}`);
|
||||
}
|
||||
|
||||
runAudit();
|
||||
@@ -1,826 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-scheduler-cycle.mjs — TORCH Agent Scheduler
|
||||
*
|
||||
* Orchestrates one full scheduling cycle for a given cadence (daily|weekly).
|
||||
* Selects the next eligible agent from the roster, acquires a distributed Nostr
|
||||
* lock, executes the agent's prompt via a configured handoff command, verifies
|
||||
* required artifacts and memory evidence, then publishes completion.
|
||||
*
|
||||
* This script owns the full lifecycle for a single agent slot per invocation:
|
||||
* lock acquisition → prompt execution → artifact verification → lock:complete.
|
||||
* Spawned agents MUST NOT call lock:complete themselves.
|
||||
*
|
||||
* Main flow (mirrors src/prompts/scheduler-flow.md numbered MUST steps):
|
||||
* 1. Parse cadence (daily|weekly), platform, and model from CLI args.
|
||||
* 2. Load roster from src/prompts/roster.json.
|
||||
* 3. Read AGENTS.md policy file (best-effort, non-fatal if missing).
|
||||
* 4. [loop] Read cadence run-state (deferral tracking for the current UTC day).
|
||||
* 5. [loop] Optional lock health preflight — fail/defer if all relays unhealthy.
|
||||
* 6. [loop] Run lock:check to build the exclusion set (locked + paused + completed).
|
||||
* 7. [loop] Apply local time-window guard (24 h daily / 7 d weekly) to exclusion set.
|
||||
* 8. [loop] Round-robin select next eligible agent from roster minus exclusion set.
|
||||
* 9. [loop] If no agent eligible → exit (cycle-saturated or all-excluded).
|
||||
* 10. [loop] Acquire lock with retry. exit 3 → back to step 4; exit 2 → defer or fail.
|
||||
* 11. [loop] Validate prompt file (readable, correct format).
|
||||
* 12. [loop] Clear deferral state. Run memory retrieve command (if configured).
|
||||
* 13. [loop] Run handoff command — hard fail if missing or exits non-zero.
|
||||
* 14. [loop] Run memory store command (if configured).
|
||||
* 15. [loop] Verify memory evidence (markers/artifact files).
|
||||
* 16. [loop] Verify run artifacts via verify-run-artifacts.mjs.
|
||||
* 17. [loop] Run validation commands (default: npm run lint).
|
||||
* 18. [loop] Publish lock:complete — hard fail if relay error.
|
||||
* 19. [loop] Write completed task log and print run summary. Exit 0.
|
||||
*
|
||||
* Key invariants:
|
||||
* - lock:complete is called ONLY after all validation gates pass (step 18 is last gate).
|
||||
* - Completed task log is written ONLY after lock:complete succeeds.
|
||||
* - Lock exit code 3 (race lost) causes a loop restart to pick the next agent.
|
||||
* - Memory mode 'required' turns missing evidence into a hard failure.
|
||||
*/
|
||||
// Source of truth: numbered MUST steps 2 and 4-16 in src/prompts/scheduler-flow.md are
|
||||
// implemented by this script; step 3 (policy-file read) is best-effort and non-fatal.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
runCommand,
|
||||
parseJsonFromOutput,
|
||||
readJson,
|
||||
parseNonNegativeInt,
|
||||
excerptText,
|
||||
buildRecentlyRunExclusionSet,
|
||||
parseDateValue,
|
||||
parseFrontmatterCreatedAt,
|
||||
parseFrontmatterAgent,
|
||||
parseTimestampFromFilename,
|
||||
isStrictSchedulerLogFilename,
|
||||
parseAgentFromFilename,
|
||||
} from './scheduler-utils.mjs';
|
||||
|
||||
import {
|
||||
classifyLockBackendError,
|
||||
buildLockBackendRemediation,
|
||||
runLockHealthPreflight,
|
||||
acquireLockWithRetry,
|
||||
summarizeLockFailureReasons,
|
||||
} from './scheduler-lock.mjs';
|
||||
|
||||
import {
|
||||
VALID_CADENCES,
|
||||
parseArgs,
|
||||
getSchedulerConfig,
|
||||
} from './scheduler-config.mjs';
|
||||
|
||||
import {
|
||||
ts,
|
||||
writeLog,
|
||||
exitWithSummary,
|
||||
categorizeFailureMetadata,
|
||||
} from './scheduler-logger.mjs';
|
||||
|
||||
import {
|
||||
readRunState,
|
||||
writeRunState,
|
||||
createIdempotencyKey,
|
||||
} from './scheduler-state.mjs';
|
||||
|
||||
const ALL_EXCLUDED_REASON = 'All roster tasks currently claimed by other agents';
|
||||
const FAILURE_CATEGORY = {
|
||||
PROMPT_PARSE: 'prompt_parse_error',
|
||||
PROMPT_SCHEMA: 'prompt_schema_error',
|
||||
LOCK_BACKEND: 'lock_backend_error',
|
||||
EXECUTION: 'execution_error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that a prompt file is readable and contains a valid markdown heading
|
||||
* or blockquote as its first non-empty line.
|
||||
*
|
||||
* This is a lightweight schema check — it does not parse frontmatter or validate
|
||||
* the full prompt contract (that is handled by validate-prompt-contract.mjs).
|
||||
*
|
||||
* @param {string} promptPath - Absolute path to the prompt markdown file.
|
||||
* @returns {Promise<{ok: true}|{ok: false, category: string, reason: string, detail: string}>}
|
||||
*/
|
||||
async function validatePromptFile(promptPath) {
|
||||
let content;
|
||||
try {
|
||||
content = await fs.readFile(promptPath, 'utf8');
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
category: FAILURE_CATEGORY.PROMPT_PARSE,
|
||||
reason: 'Prompt file parse/read failed',
|
||||
detail: `Prompt not executed; unable to read prompt file at ${promptPath}: ${error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = String(content).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const firstLine = lines[0] || '';
|
||||
if (!firstLine.startsWith('#') && !firstLine.startsWith('>')) {
|
||||
return {
|
||||
ok: false,
|
||||
category: FAILURE_CATEGORY.PROMPT_SCHEMA,
|
||||
reason: 'Prompt file schema validation failed',
|
||||
detail: `Prompt not executed; expected markdown heading or blockquote on first non-empty line in ${promptPath}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a file exists and was modified at or after `sinceMs`.
|
||||
* Used to verify that memory evidence artifacts were produced during the current run.
|
||||
*
|
||||
* @param {string|null} filePath - Relative or absolute path to check.
|
||||
* @param {number} sinceMs - Epoch milliseconds threshold (run start time).
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function artifactExistsSince(filePath, sinceMs) {
|
||||
if (!filePath) return false;
|
||||
const resolved = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(process.cwd(), filePath);
|
||||
try {
|
||||
const stat = await fs.stat(resolved);
|
||||
return stat.isFile() && stat.mtimeMs >= sinceMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a memory pipeline step (retrieve or store) produced observable evidence.
|
||||
* Evidence can be either a deterministic output marker string OR a qualifying artifact file.
|
||||
* A step is considered complete when either form of evidence is present.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.name - Step name ('retrieve'|'store') for logging.
|
||||
* @param {string[]} params.markers - Substrings to search for in combined command output.
|
||||
* @param {string[]} params.artifacts - Relative paths of artifact files to check.
|
||||
* @param {string} params.outputText - Combined stdout+stderr from all commands this run.
|
||||
* @param {number} params.sinceMs - Run start epoch ms; artifact files must be newer.
|
||||
* @returns {Promise<{name: string, markerMatched: boolean, artifactMatched: boolean, complete: boolean}>}
|
||||
*/
|
||||
async function verifyMemoryStep({ name, markers, artifacts, outputText, sinceMs }) {
|
||||
const markerMatched = markers.some((marker) => outputText.includes(marker));
|
||||
const artifactResults = await Promise.all(artifacts.map((artifact) => artifactExistsSince(artifact, sinceMs)));
|
||||
const artifactMatched = artifactResults.some(Boolean);
|
||||
|
||||
return {
|
||||
name,
|
||||
markerMatched,
|
||||
artifactMatched,
|
||||
complete: markerMatched || artifactMatched,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Finds the most recent valid scheduler task log file in `logDir`.
|
||||
* "Valid" means the filename passes isStrictSchedulerLogFilename() AND the file
|
||||
* contains a parseable timestamp (from frontmatter created_at or the filename itself).
|
||||
*
|
||||
* Files with unparseable timestamps are skipped with a console warning.
|
||||
* The effective timestamp for sorting is derived from frontmatter first, then filename.
|
||||
*
|
||||
* @param {string} logDir - Absolute path to the cadence log directory (e.g. task-logs/daily/).
|
||||
* @returns {Promise<string|null>} Filename (not path) of the latest log, or null if none.
|
||||
*/
|
||||
async function getLatestFile(logDir) {
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
const entries = await fs.readdir(logDir, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name)
|
||||
.filter((filename) => isStrictSchedulerLogFilename(filename))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const results = await Promise.all(
|
||||
files.map(async (filename) => {
|
||||
const filePath = path.join(logDir, filename);
|
||||
const content = await fs.readFile(filePath, 'utf8').catch(() => '');
|
||||
const createdAtMs = parseDateValue(parseFrontmatterCreatedAt(content));
|
||||
const fileTimestampMs = parseTimestampFromFilename(filename);
|
||||
const effectiveMs = createdAtMs ?? fileTimestampMs;
|
||||
|
||||
if (!effectiveMs) {
|
||||
console.warn(`[scheduler] Ignoring invalid log timestamp in ${filename}; checking next candidate.`);
|
||||
return null;
|
||||
}
|
||||
return { filename, effectiveMs };
|
||||
}),
|
||||
);
|
||||
|
||||
let latest = null;
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) continue;
|
||||
if (!latest || result.effectiveMs > latest.effectiveMs) {
|
||||
latest = result;
|
||||
}
|
||||
}
|
||||
|
||||
return latest?.filename || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the next eligible agent from the roster using round-robin scheduling.
|
||||
*
|
||||
* Selection algorithm:
|
||||
* - If `previousAgent` is in the roster, start scanning from the next index (wrap-around).
|
||||
* - Otherwise, start from `firstPrompt` index (if configured) or index 0.
|
||||
* - Returns the first candidate not present in `excludedSet`.
|
||||
* - Returns null if every roster agent is excluded.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string[]} params.roster - Ordered array of agent names for this cadence.
|
||||
* @param {Set<string>} params.excludedSet - Agents to skip (locked, paused, completed, time-excluded).
|
||||
* @param {string|null} params.previousAgent - Agent that ran most recently (from latest log).
|
||||
* @param {string|null} params.firstPrompt - Configured first-run starting agent name.
|
||||
* @returns {string|null} Selected agent name, or null if roster is fully excluded.
|
||||
*/
|
||||
function selectNextAgent({ roster, excludedSet, previousAgent, firstPrompt }) {
|
||||
const previousIndex = roster.indexOf(previousAgent);
|
||||
const firstIndex = roster.indexOf(firstPrompt);
|
||||
const startIndex = previousIndex >= 0
|
||||
? (previousIndex + 1) % roster.length
|
||||
: (firstIndex >= 0 ? firstIndex : 0);
|
||||
|
||||
for (let offset = 0; offset < roster.length; offset += 1) {
|
||||
const candidate = roster[(startIndex + offset) % roster.length];
|
||||
if (!excludedSet.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { cadence, platform, model } = parseArgs(process.argv.slice(2));
|
||||
if (!VALID_CADENCES.has(cadence)) {
|
||||
console.error('Usage: node scripts/agent/run-scheduler-cycle.mjs <daily|weekly>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roster = (await readJson(path.resolve(process.cwd(), 'src/prompts/roster.json'), {}))[cadence] || [];
|
||||
if (!Array.isArray(roster) || roster.length === 0) {
|
||||
console.error(`No roster entries for cadence ${cadence}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
const schedulerConfig = await getSchedulerConfig(cadence, { isInteractive });
|
||||
const logDir = path.resolve(process.cwd(), 'task-logs', cadence);
|
||||
|
||||
const agentsPath = path.resolve(process.cwd(), 'AGENTS.md');
|
||||
try {
|
||||
const agentsContent = await fs.readFile(agentsPath, 'utf8');
|
||||
process.stdout.write(`${agentsContent}\n`);
|
||||
} catch {
|
||||
console.log('No AGENTS.md found; continuing');
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { statePath: runStatePath, state: schedulerRunState } = await readRunState(cadence);
|
||||
|
||||
if (schedulerConfig.lockHealthPreflight.enabled && !schedulerConfig.lockHealthPreflight.skip) {
|
||||
const preflight = await runLockHealthPreflight({ cadence, platform });
|
||||
if (preflight.code !== 0) {
|
||||
const allRelaysUnhealthy = Boolean(preflight.payload?.summary?.allRelaysUnhealthy);
|
||||
const incidentSignal = preflight.payload?.incidentSignal || null;
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: 'scheduler',
|
||||
status: allRelaysUnhealthy ? 'deferred' : 'failed',
|
||||
platform,
|
||||
reason: allRelaysUnhealthy
|
||||
? 'All relays unhealthy preflight'
|
||||
: 'Lock backend unavailable preflight',
|
||||
detail: allRelaysUnhealthy
|
||||
? `Deferred run before lock acquisition: ${incidentSignal?.reason || 'all relays unhealthy'}. Prompt not executed. ${buildLockBackendRemediation({ cadence, retryWindowMs: schedulerConfig.lockFailurePolicy.degradedLockRetryWindowMs, maxDeferrals: schedulerConfig.lockFailurePolicy.maxDeferrals, incidentSignalId: incidentSignal?.id || null })}`
|
||||
: `Preflight failed (${preflight.failureCategory}). Prompt not executed. ${buildLockBackendRemediation({ cadence, retryWindowMs: schedulerConfig.lockFailurePolicy.degradedLockRetryWindowMs, maxDeferrals: schedulerConfig.lockFailurePolicy.maxDeferrals, incidentSignalId: incidentSignal?.id || null })}`,
|
||||
metadata: {
|
||||
...categorizeFailureMetadata(FAILURE_CATEGORY.LOCK_BACKEND, { failure_class: 'backend_unavailable' }),
|
||||
preflight_failure_category: preflight.failureCategory,
|
||||
relay_list: preflight.relayList.join(', ') || '(none)',
|
||||
preflight_stderr_excerpt: preflight.stderrExcerpt || '(empty)',
|
||||
preflight_stdout_excerpt: preflight.stdoutExcerpt || '(empty)',
|
||||
incident_signal_id: incidentSignal?.id || null,
|
||||
incident_signal_severity: incidentSignal?.severity || null,
|
||||
preflight_alerts: JSON.stringify(preflight.payload?.alerts || []),
|
||||
relay_health_history_path: preflight.payload?.historyPath || null,
|
||||
},
|
||||
});
|
||||
await exitWithSummary(allRelaysUnhealthy ? 0 : (preflight.code || 1), {
|
||||
status: allRelaysUnhealthy ? 'deferred' : 'failed',
|
||||
agent: 'scheduler',
|
||||
promptPath: null,
|
||||
reason: allRelaysUnhealthy ? 'All relays unhealthy preflight' : 'Lock backend unavailable preflight',
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Build exclusion set from relay state. Prefer the top-level `excluded`
|
||||
// field (computed by cmdCheck), falling back to the union of locked/paused/completed.
|
||||
const checkResult = await runCommand('npm', ['run', `lock:check:${cadence}`, '--', '--json', '--quiet']);
|
||||
const checkPayload = parseJsonFromOutput(`${checkResult.stdout}\n${checkResult.stderr}`) || {};
|
||||
const excluded = Array.isArray(checkPayload.excluded)
|
||||
? checkPayload.excluded
|
||||
: [...new Set([
|
||||
...(Array.isArray(checkPayload.locked) ? checkPayload.locked : []),
|
||||
...(Array.isArray(checkPayload.paused) ? checkPayload.paused : []),
|
||||
...(Array.isArray(checkPayload.completed) ? checkPayload.completed : []),
|
||||
])];
|
||||
const excludedSet = new Set(excluded);
|
||||
|
||||
// Time-window guard: exclude agents that already ran within the cadence window
|
||||
// (24 h for daily, 7 d for weekly). This catches the cross-midnight edge case
|
||||
// where the relay's date-scoped lock has rolled over but the run was recent.
|
||||
const windowLabel = cadence === 'weekly' ? '7-day' : '24-hour';
|
||||
const recentlyRunSet = await buildRecentlyRunExclusionSet(logDir, cadence);
|
||||
const newlyTimeExcluded = [...recentlyRunSet].filter((agent) => !excludedSet.has(agent));
|
||||
for (const agent of recentlyRunSet) {
|
||||
excludedSet.add(agent);
|
||||
}
|
||||
if (newlyTimeExcluded.length > 0) {
|
||||
console.log(`[scheduler] Time-window guard (${windowLabel}): ${newlyTimeExcluded.join(', ')} excluded — ran within window`);
|
||||
}
|
||||
|
||||
const latestFile = await getLatestFile(logDir);
|
||||
let previousAgent = null;
|
||||
|
||||
if (latestFile) {
|
||||
const latestPath = path.join(logDir, latestFile);
|
||||
const content = await fs.readFile(latestPath, 'utf8').catch(() => '');
|
||||
previousAgent = parseFrontmatterAgent(content) || parseAgentFromFilename(latestFile);
|
||||
if (!roster.includes(previousAgent)) {
|
||||
previousAgent = null;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAgent = selectNextAgent({
|
||||
roster,
|
||||
excludedSet,
|
||||
previousAgent,
|
||||
firstPrompt: schedulerConfig.firstPrompt,
|
||||
});
|
||||
|
||||
if (!selectedAgent) {
|
||||
// Cycle saturation: every roster agent has a recent local log within the
|
||||
// window. This is the expected steady state once a full rotation completes.
|
||||
// Exit 0 so the triggering process (cron, CI) does not treat it as an error.
|
||||
const allCycleSaturated = roster.every((agent) => recentlyRunSet.has(agent));
|
||||
if (allCycleSaturated) {
|
||||
const reason = `Cycle complete: all ${roster.length} agents ran within the ${windowLabel} window`;
|
||||
console.log(`[scheduler] ${reason}. Nothing to do.`);
|
||||
await exitWithSummary(0, {
|
||||
status: 'skipped',
|
||||
agent: 'scheduler',
|
||||
promptPath: null,
|
||||
reason,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: 'scheduler',
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: ALL_EXCLUDED_REASON,
|
||||
});
|
||||
await exitWithSummary(1, {
|
||||
status: 'failed',
|
||||
agent: 'scheduler',
|
||||
promptPath: null,
|
||||
reason: ALL_EXCLUDED_REASON,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
const deferralForAgent = schedulerRunState.lock_deferral?.selected_agent === selectedAgent
|
||||
? schedulerRunState.lock_deferral
|
||||
: null;
|
||||
|
||||
const lockAttempt = await acquireLockWithRetry({
|
||||
selectedAgent,
|
||||
cadence,
|
||||
platform,
|
||||
model,
|
||||
lockRetry: schedulerConfig.lockRetry,
|
||||
idempotencyKey: deferralForAgent?.idempotency_key,
|
||||
});
|
||||
const lockResult = lockAttempt.result;
|
||||
|
||||
// Lock exit 3: race lost — another agent claimed this slot between our check
|
||||
// and our publish. Restart the loop to pick the next available agent.
|
||||
if (lockResult.code === 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lockResult.code === 2) {
|
||||
const combinedLockOutput = `${lockResult.stderr}\n${lockResult.stdout}`;
|
||||
const diagnosticsSummary = summarizeLockFailureReasons(combinedLockOutput);
|
||||
const backendCategory = lockAttempt.finalBackendCategory || classifyLockBackendError(combinedLockOutput);
|
||||
const modelPart = model ? ` --model ${model}` : '';
|
||||
const lockCommand = `AGENT_PLATFORM=${platform} npm run lock:lock -- --agent ${selectedAgent} --cadence ${cadence}${modelPart}`;
|
||||
const stderrExcerpt = excerptText(lockResult.stderr);
|
||||
const stdoutExcerpt = excerptText(lockResult.stdout);
|
||||
const backoffSchedule = lockAttempt.backoffScheduleMs.join(', ');
|
||||
|
||||
const existingDeferral = deferralForAgent || {};
|
||||
const firstFailureAt = existingDeferral.first_failure_timestamp || new Date().toISOString();
|
||||
const firstFailureMs = parseDateValue(firstFailureAt) || Date.now();
|
||||
const nowMs = Date.now();
|
||||
const deferralAttemptCount = parseNonNegativeInt(existingDeferral.attempt_count, 0) + 1;
|
||||
const idempotencyKey = existingDeferral.idempotency_key
|
||||
|| createIdempotencyKey({ cadence, selectedAgent, runDate: schedulerRunState.run_date });
|
||||
const withinRetryWindow = nowMs - firstFailureMs <= schedulerConfig.lockFailurePolicy.degradedLockRetryWindowMs;
|
||||
const withinDeferralBudget = deferralAttemptCount <= schedulerConfig.lockFailurePolicy.maxDeferrals;
|
||||
|
||||
if (!schedulerConfig.lockFailurePolicy.strictLock && withinRetryWindow && withinDeferralBudget) {
|
||||
schedulerRunState.lock_deferral = {
|
||||
attempt_count: deferralAttemptCount,
|
||||
first_failure_timestamp: firstFailureAt,
|
||||
backend_category: backendCategory,
|
||||
idempotency_key: idempotencyKey,
|
||||
selected_agent: selectedAgent,
|
||||
};
|
||||
await writeRunState(runStatePath, schedulerRunState);
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'deferred',
|
||||
platform,
|
||||
reason: 'Lock backend deferred',
|
||||
detail: `Deferred after lock backend failure (${backendCategory}); retry window active and deferral budget remaining. Prompt not executed. ${buildLockBackendRemediation({ cadence, retryWindowMs: schedulerConfig.lockFailurePolicy.degradedLockRetryWindowMs, maxDeferrals: schedulerConfig.lockFailurePolicy.maxDeferrals })}`,
|
||||
metadata: {
|
||||
...categorizeFailureMetadata(FAILURE_CATEGORY.LOCK_BACKEND, { failure_class: 'backend_unavailable' }),
|
||||
deferral_attempt_count: deferralAttemptCount,
|
||||
deferral_first_failure_timestamp: firstFailureAt,
|
||||
backend_category: backendCategory,
|
||||
lock_idempotency_key: idempotencyKey,
|
||||
},
|
||||
});
|
||||
await exitWithSummary(0, {
|
||||
status: 'deferred',
|
||||
agent: selectedAgent,
|
||||
promptPath: null,
|
||||
reason: 'Lock backend deferred',
|
||||
platform,
|
||||
detail: `Lock backend failure (${backendCategory})`,
|
||||
});
|
||||
}
|
||||
|
||||
schedulerRunState.lock_deferral = null;
|
||||
await writeRunState(runStatePath, schedulerRunState);
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Lock backend error',
|
||||
detail: `Lock backend error (${backendCategory}) after ${lockAttempt.attempts} attempt(s). Prompt not executed. ${buildLockBackendRemediation({ cadence, retryWindowMs: schedulerConfig.lockFailurePolicy.degradedLockRetryWindowMs, maxDeferrals: schedulerConfig.lockFailurePolicy.maxDeferrals })}`,
|
||||
metadata: {
|
||||
...categorizeFailureMetadata(FAILURE_CATEGORY.LOCK_BACKEND, { failure_class: 'backend_unavailable' }),
|
||||
lock_attempts_total: lockAttempt.attempts,
|
||||
lock_backoff_schedule_ms: backoffSchedule || '(none)',
|
||||
lock_correlation_id: diagnosticsSummary.correlationId || lockAttempt.correlationId,
|
||||
lock_attempt_id: diagnosticsSummary.attemptId || String(lockAttempt.attempts),
|
||||
lock_total_retry_timeline_ms: diagnosticsSummary.totalElapsedMs,
|
||||
lock_failure_reason_distribution: JSON.stringify(diagnosticsSummary.reasonDistribution),
|
||||
backend_category: backendCategory,
|
||||
deferral_attempt_count: deferralAttemptCount,
|
||||
deferral_first_failure_timestamp: firstFailureAt,
|
||||
lock_command: lockCommand,
|
||||
lock_idempotency_key: idempotencyKey,
|
||||
lock_stderr_excerpt: stderrExcerpt || '(empty)',
|
||||
lock_stdout_excerpt: stdoutExcerpt || '(empty)',
|
||||
},
|
||||
});
|
||||
await exitWithSummary(2, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath: null,
|
||||
reason: 'Lock backend error',
|
||||
platform,
|
||||
detail: `Lock backend error (${backendCategory})`,
|
||||
});
|
||||
}
|
||||
|
||||
if (lockResult.code !== 0) {
|
||||
schedulerRunState.lock_deferral = null;
|
||||
await writeRunState(runStatePath, schedulerRunState);
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Failed to acquire lock',
|
||||
});
|
||||
await exitWithSummary(lockResult.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath: null,
|
||||
reason: 'Failed to acquire lock',
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
const promptPath = path.resolve(process.cwd(), 'src/prompts', cadence, `${selectedAgent}.md`);
|
||||
const promptValidation = await validatePromptFile(promptPath);
|
||||
if (!promptValidation.ok) {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: promptValidation.reason,
|
||||
detail: promptValidation.detail,
|
||||
metadata: categorizeFailureMetadata(promptValidation.category, { prompt_path: promptPath }),
|
||||
});
|
||||
await exitWithSummary(1, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: promptValidation.reason,
|
||||
detail: promptValidation.detail,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
schedulerRunState.lock_deferral = null;
|
||||
await writeRunState(runStatePath, schedulerRunState);
|
||||
const runArtifactSince = new Date().toISOString();
|
||||
const runArtifactTimestamp = ts();
|
||||
const runStartMs = Date.parse(runArtifactSince);
|
||||
const outputChunks = [];
|
||||
|
||||
// Pre-create the memory update file path and inject it as SCHEDULER_MEMORY_FILE
|
||||
// so the spawned agent can write learnings without computing the path itself.
|
||||
const memoryDir = path.resolve(process.cwd(), 'memory-updates');
|
||||
await fs.mkdir(memoryDir, { recursive: true });
|
||||
const memoryFile = path.join(memoryDir, `${ts()}__${selectedAgent}.md`);
|
||||
const schedulerEnv = {
|
||||
AGENT_PLATFORM: platform,
|
||||
...(model ? { AGENT_MODEL: model } : {}),
|
||||
SCHEDULER_AGENT: selectedAgent,
|
||||
SCHEDULER_CADENCE: cadence,
|
||||
SCHEDULER_PROMPT_PATH: promptPath,
|
||||
SCHEDULER_MEMORY_FILE: memoryFile,
|
||||
SCHEDULER_RUN_TIMESTAMP: runArtifactTimestamp,
|
||||
};
|
||||
|
||||
if (schedulerConfig.memoryPolicy.retrieveCommand) {
|
||||
const retrieveResult = await runCommand('bash', ['-lc', schedulerConfig.memoryPolicy.retrieveCommand], {
|
||||
env: schedulerEnv,
|
||||
});
|
||||
outputChunks.push(retrieveResult.stdout, retrieveResult.stderr);
|
||||
if (retrieveResult.code !== 0) {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Memory retrieval command failed',
|
||||
detail: schedulerConfig.memoryPolicy.retrieveCommand,
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.EXECUTION, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(retrieveResult.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Memory retrieval command failed',
|
||||
detail: schedulerConfig.memoryPolicy.retrieveCommand,
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (schedulerConfig.handoffCommand) {
|
||||
const handoff = await runCommand('bash', ['-lc', schedulerConfig.handoffCommand], {
|
||||
env: schedulerEnv,
|
||||
});
|
||||
outputChunks.push(handoff.stdout, handoff.stderr);
|
||||
if (handoff.code !== 0) {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Prompt/handoff execution failed',
|
||||
detail: 'Handoff callback failed.',
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.EXECUTION, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(handoff.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Prompt/handoff execution failed',
|
||||
detail: 'Handoff callback failed',
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const detail = schedulerConfig.missingHandoffCommandForMode
|
||||
? 'Missing scheduler handoff command for non-interactive run. Set scheduler.handoffCommandByCadence.daily|weekly in torch-config.json.'
|
||||
: 'No handoff callback configured. Set scheduler.handoffCommandByCadence.daily|weekly in torch-config.json.';
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Prompt/handoff execution failed',
|
||||
detail,
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.EXECUTION, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(1, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Prompt/handoff execution failed',
|
||||
detail,
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
if (schedulerConfig.memoryPolicy.storeCommand) {
|
||||
const storeResult = await runCommand('bash', ['-lc', schedulerConfig.memoryPolicy.storeCommand], {
|
||||
env: schedulerEnv,
|
||||
});
|
||||
outputChunks.push(storeResult.stdout, storeResult.stderr);
|
||||
if (storeResult.code !== 0) {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Memory storage command failed',
|
||||
detail: schedulerConfig.memoryPolicy.storeCommand,
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.EXECUTION, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(storeResult.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Memory storage command failed',
|
||||
detail: schedulerConfig.memoryPolicy.storeCommand,
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const memoryOutput = outputChunks.join('\n');
|
||||
const retrieveCheck = await verifyMemoryStep({
|
||||
name: 'retrieve',
|
||||
markers: schedulerConfig.memoryPolicy.retrieveSuccessMarkers,
|
||||
artifacts: schedulerConfig.memoryPolicy.retrieveArtifacts,
|
||||
outputText: memoryOutput,
|
||||
sinceMs: runStartMs,
|
||||
});
|
||||
const storeCheck = await verifyMemoryStep({
|
||||
name: 'store',
|
||||
markers: schedulerConfig.memoryPolicy.storeSuccessMarkers,
|
||||
artifacts: schedulerConfig.memoryPolicy.storeArtifacts,
|
||||
outputText: memoryOutput,
|
||||
sinceMs: runStartMs,
|
||||
});
|
||||
|
||||
// mode='required' → missing evidence is a hard failure; mode='optional' → warning only.
|
||||
const missingSteps = [retrieveCheck, storeCheck].filter((step) => !step.complete).map((step) => step.name);
|
||||
if (missingSteps.length && schedulerConfig.memoryPolicy.mode === 'required') {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Required memory steps not verified',
|
||||
detail: `Missing evidence for: ${missingSteps.join(', ')}`,
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.PROMPT_SCHEMA, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(1, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Required memory steps not verified',
|
||||
detail: `Missing evidence for: ${missingSteps.join(', ')}`,
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
if (missingSteps.length) {
|
||||
console.warn(`[scheduler] Optional memory evidence missing for ${missingSteps.join(', ')}.`);
|
||||
}
|
||||
|
||||
const artifactCheck = await runCommand('node', [
|
||||
'scripts/agent/verify-run-artifacts.mjs',
|
||||
'--since',
|
||||
runArtifactSince,
|
||||
'--agent',
|
||||
selectedAgent,
|
||||
'--cadence',
|
||||
cadence,
|
||||
'--prompt-path',
|
||||
promptPath,
|
||||
'--run-start',
|
||||
runArtifactSince,
|
||||
'--check-failure-notes',
|
||||
]);
|
||||
if (artifactCheck.code !== 0) {
|
||||
const detail = artifactCheck.stderr.trim() || artifactCheck.stdout.trim() || 'Artifact verification failed.';
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Missing required run artifacts',
|
||||
detail,
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.PROMPT_SCHEMA, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(artifactCheck.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Missing required run artifacts',
|
||||
detail,
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
for (const validation of schedulerConfig.validationCommands) {
|
||||
const parts = validation.split(' ').filter(Boolean);
|
||||
if (!parts.length) continue;
|
||||
const result = await runCommand(parts[0], parts.slice(1));
|
||||
if (result.code !== 0) {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: 'Validation failed',
|
||||
detail: validation,
|
||||
metadata: categorizeFailureMetadata(FAILURE_CATEGORY.EXECUTION, { failure_class: 'prompt_validation_error' }),
|
||||
});
|
||||
await exitWithSummary(result.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Validation failed',
|
||||
detail: validation,
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const completeResult = await runCommand(
|
||||
'npm',
|
||||
['run', 'lock:complete', '--', '--agent', selectedAgent, '--cadence', cadence, ...(model ? ['--model', model] : [])],
|
||||
{ env: { AGENT_PLATFORM: platform, ...(model ? { AGENT_MODEL: model } : {}) } },
|
||||
);
|
||||
|
||||
if (completeResult.code !== 0) {
|
||||
await writeLog({
|
||||
cadence,
|
||||
agent: selectedAgent,
|
||||
status: 'failed',
|
||||
platform,
|
||||
reason: `Completion publish failed. Retry npm run lock:complete -- --agent ${selectedAgent} --cadence ${cadence} after verifying relay connectivity`,
|
||||
});
|
||||
await exitWithSummary(completeResult.code, {
|
||||
status: 'failed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Completion publish failed',
|
||||
detail: 'Retry npm run lock:complete after verifying relay connectivity',
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
await writeLog({ cadence, agent: selectedAgent, status: 'completed', platform, reason: 'Scheduler cycle completed successfully' });
|
||||
await exitWithSummary(0, {
|
||||
status: 'completed',
|
||||
agent: selectedAgent,
|
||||
promptPath,
|
||||
reason: 'Scheduler cycle completed successfully',
|
||||
memoryFile,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const REQUIRED_ENV = ['SCHEDULER_PROMPT_PATH', 'SCHEDULER_AGENT', 'SCHEDULER_CADENCE'];
|
||||
|
||||
function fail(message, code = 1) {
|
||||
console.error(`[scheduler] ${message}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function resolveRunner(promptMarkdown) {
|
||||
const customRunner = process.env.SCHEDULER_AGENT_RUNNER_COMMAND?.trim();
|
||||
if (customRunner) {
|
||||
return { command: 'bash', args: ['-lc', customRunner] };
|
||||
}
|
||||
|
||||
const platform = (process.env.AGENT_PLATFORM || 'codex').trim().toLowerCase();
|
||||
if (platform === 'codex') {
|
||||
return { command: 'codex', args: ['exec', promptMarkdown] };
|
||||
}
|
||||
|
||||
if (platform === 'claude') {
|
||||
return { command: 'claude', args: ['-p', promptMarkdown] };
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return { command: 'echo', args: [`[scheduler] Platform 'linux' detected. simulated execution for prompt.`] };
|
||||
}
|
||||
|
||||
fail(`Unsupported AGENT_PLATFORM="${platform}". Set SCHEDULER_AGENT_RUNNER_COMMAND to override.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const key of REQUIRED_ENV) {
|
||||
if (!process.env[key] || !String(process.env[key]).trim()) {
|
||||
fail(`Missing required environment variable ${key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const promptPath = path.resolve(process.cwd(), process.env.SCHEDULER_PROMPT_PATH);
|
||||
let promptMarkdown;
|
||||
|
||||
try {
|
||||
promptMarkdown = await fs.readFile(promptPath, 'utf8');
|
||||
} catch {
|
||||
fail(`Unable to read scheduler prompt at ${promptPath}.`);
|
||||
}
|
||||
|
||||
if (!promptMarkdown.trim()) {
|
||||
fail(`Scheduler prompt at ${promptPath} is empty.`);
|
||||
}
|
||||
|
||||
const cadence = process.env.SCHEDULER_CADENCE;
|
||||
const memoriesPath = path.resolve(process.cwd(), `.scheduler-memory/latest/${cadence}/memories.md`);
|
||||
try {
|
||||
const memories = await fs.readFile(memoriesPath, 'utf8');
|
||||
if (memories && memories.trim()) {
|
||||
promptMarkdown += `\n\n# Retrieved Memories\n\n${memories.trim()}\n`;
|
||||
console.error(`[scheduler] Injected memories from ${memoriesPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error(`[scheduler] Warning: Failed to read memories from ${memoriesPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const runner = resolveRunner(promptMarkdown);
|
||||
|
||||
// Compute a stable, per-run memory output file path so agents know where to write learnings.
|
||||
// store.mjs reads this env var and ingests this file.
|
||||
const agentName = process.env.SCHEDULER_AGENT || 'unknown-agent';
|
||||
const runTs = new Date().toISOString().replace(/:/g, '-').replace(/\.\d+Z$/, 'Z');
|
||||
const memoryFile = `memory-updates/${runTs}__${agentName}.md`;
|
||||
|
||||
const child = spawn(runner.command, runner.args, {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
SCHEDULER_PROMPT_PATH: promptPath,
|
||||
SCHEDULER_PROMPT_MARKDOWN: promptMarkdown,
|
||||
SCHEDULER_MEMORY_FILE: memoryFile,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
fail(`Runner command not found: ${runner.command}`, 127);
|
||||
}
|
||||
fail(`Runner execution failed: ${error.message}`);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,164 +0,0 @@
|
||||
import fsSync from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
readJson,
|
||||
normalizeStringList,
|
||||
parseNonNegativeInt,
|
||||
parseBooleanFlag,
|
||||
} from './scheduler-utils.mjs';
|
||||
import { detectPlatform } from '../../src/utils.mjs';
|
||||
import { getTorchConfigPath } from '../../src/torch-config.mjs';
|
||||
|
||||
export const VALID_CADENCES = new Set(['daily', 'weekly']);
|
||||
|
||||
/**
|
||||
* Parses CLI arguments into a structured options object.
|
||||
* Supports both positional cadence (`daily`|`weekly`) and named flags.
|
||||
* Falls back to AGENT_PLATFORM env var or auto-detected platform for platform.
|
||||
*
|
||||
* @param {string[]} argv - process.argv slice (args after the script name)
|
||||
* @returns {{ cadence: string|null, platform: string, model: string|null }}
|
||||
*/
|
||||
export function parseArgs(argv) {
|
||||
const args = { cadence: null, platform: process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', model: process.env.AGENT_MODEL || null };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const value = argv[i];
|
||||
if (!value.startsWith('--') && !args.cadence) {
|
||||
args.cadence = value;
|
||||
continue;
|
||||
}
|
||||
if (value === '--cadence') {
|
||||
args.cadence = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--platform') {
|
||||
args.platform = argv[i + 1] || args.platform;
|
||||
i += 1;
|
||||
}
|
||||
if (value === '--model') {
|
||||
args.model = argv[i + 1] || args.model;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and normalizes scheduler configuration for the given cadence.
|
||||
* Merges torch-config.json settings with environment variable overrides.
|
||||
*
|
||||
* Configuration covers:
|
||||
* - handoffCommand: shell command to execute the selected agent's prompt.
|
||||
* - validationCommands: commands that must pass before lock:complete (default: npm run lint).
|
||||
* - lockRetry: maxRetries, backoffMs, jitterMs for lock acquisition retries.
|
||||
* - lockHealthPreflight: whether to probe relay health before lock acquisition.
|
||||
* - lockFailurePolicy: strictLock, degradedLockRetryWindowMs, maxDeferrals.
|
||||
* - memoryPolicy: mode (required|optional), retrieve/store commands, markers, artifacts.
|
||||
*
|
||||
* Environment variable overrides (all optional):
|
||||
* SCHEDULER_LOCK_MAX_RETRIES, SCHEDULER_LOCK_BACKOFF_MS, SCHEDULER_LOCK_JITTER_MS,
|
||||
* SCHEDULER_LOCK_HEALTH_PREFLIGHT, SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT,
|
||||
* SCHEDULER_STRICT_LOCK, SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS, SCHEDULER_MAX_DEFERRALS
|
||||
*
|
||||
* @param {string} cadence - 'daily'|'weekly'
|
||||
* @param {{ isInteractive: boolean }} options
|
||||
* @returns {Promise<Object>} Normalized scheduler config object.
|
||||
*/
|
||||
export async function getSchedulerConfig(cadence, { isInteractive }) {
|
||||
const configPath = getTorchConfigPath();
|
||||
const cfg = await readJson(configPath, {});
|
||||
const configDir = path.dirname(configPath);
|
||||
const runtimeDir = process.cwd();
|
||||
|
||||
const resolveNodeCommand = (command) => {
|
||||
if (typeof command !== 'string') return command;
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed.startsWith('node ')) return trimmed;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 2) return trimmed;
|
||||
const scriptArg = parts[1];
|
||||
if (!scriptArg || scriptArg.startsWith('-') || path.isAbsolute(scriptArg)) return trimmed;
|
||||
const absoluteScriptPath = path.resolve(configDir, scriptArg);
|
||||
if (!fsSync.existsSync(absoluteScriptPath)) return trimmed;
|
||||
const rewrittenScriptPath = path.relative(runtimeDir, absoluteScriptPath).split(path.sep).join('/');
|
||||
if (!rewrittenScriptPath || rewrittenScriptPath.startsWith('..')) return trimmed;
|
||||
parts[1] = rewrittenScriptPath;
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const scheduler = cfg.scheduler || {};
|
||||
const defaultHandoffCommand = resolveNodeCommand('node scripts/agent/run-selected-prompt.mjs');
|
||||
const handoffCommandRaw = scheduler.handoffCommandByCadence?.[cadence] || defaultHandoffCommand;
|
||||
const handoffCommand = resolveNodeCommand(handoffCommandRaw);
|
||||
const missingHandoffCommandForMode = !isInteractive && !handoffCommand;
|
||||
const memoryPolicyRaw = scheduler.memoryPolicyByCadence?.[cadence] || {};
|
||||
const mode = memoryPolicyRaw.mode === 'required' ? 'required' : 'optional';
|
||||
const lockRetryRaw = scheduler.lockRetry || {};
|
||||
const maxRetries = parseNonNegativeInt(
|
||||
process.env.SCHEDULER_LOCK_MAX_RETRIES,
|
||||
parseNonNegativeInt(lockRetryRaw.maxRetries, 2),
|
||||
);
|
||||
const backoffMs = parseNonNegativeInt(
|
||||
process.env.SCHEDULER_LOCK_BACKOFF_MS,
|
||||
parseNonNegativeInt(lockRetryRaw.backoffMs, 250),
|
||||
);
|
||||
const jitterMs = parseNonNegativeInt(
|
||||
process.env.SCHEDULER_LOCK_JITTER_MS,
|
||||
parseNonNegativeInt(lockRetryRaw.jitterMs, 75),
|
||||
);
|
||||
const lockHealthPreflightFromConfig = parseBooleanFlag(scheduler.lockHealthPreflight, false);
|
||||
const lockHealthPreflightEnabled = parseBooleanFlag(
|
||||
process.env.SCHEDULER_LOCK_HEALTH_PREFLIGHT,
|
||||
lockHealthPreflightFromConfig,
|
||||
);
|
||||
const lockHealthPreflightSkip = parseBooleanFlag(process.env.SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT, false);
|
||||
const strictLock = parseBooleanFlag(
|
||||
process.env.SCHEDULER_STRICT_LOCK,
|
||||
parseBooleanFlag(scheduler.strict_lock, true),
|
||||
);
|
||||
const degradedLockRetryWindowMs = parseNonNegativeInt(
|
||||
process.env.SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS,
|
||||
parseNonNegativeInt(scheduler.degraded_lock_retry_window, 3600000),
|
||||
);
|
||||
const maxDeferrals = parseNonNegativeInt(
|
||||
process.env.SCHEDULER_MAX_DEFERRALS,
|
||||
parseNonNegativeInt(scheduler.max_deferrals, 3),
|
||||
);
|
||||
|
||||
return {
|
||||
firstPrompt: scheduler.firstPromptByCadence?.[cadence] || null,
|
||||
handoffCommand,
|
||||
missingHandoffCommandForMode,
|
||||
validationCommands: Array.isArray(scheduler.validationCommandsByCadence?.[cadence])
|
||||
? scheduler.validationCommandsByCadence[cadence].filter((cmd) => typeof cmd === 'string' && cmd.trim())
|
||||
: ['npm run lint'],
|
||||
lockRetry: {
|
||||
maxRetries,
|
||||
backoffMs,
|
||||
jitterMs,
|
||||
},
|
||||
lockHealthPreflight: {
|
||||
enabled: lockHealthPreflightEnabled,
|
||||
skip: lockHealthPreflightSkip,
|
||||
},
|
||||
lockFailurePolicy: {
|
||||
strictLock,
|
||||
degradedLockRetryWindowMs,
|
||||
maxDeferrals,
|
||||
},
|
||||
memoryPolicy: {
|
||||
mode,
|
||||
retrieveCommand: typeof memoryPolicyRaw.retrieveCommand === 'string' && memoryPolicyRaw.retrieveCommand.trim()
|
||||
? resolveNodeCommand(memoryPolicyRaw.retrieveCommand.trim())
|
||||
: null,
|
||||
storeCommand: typeof memoryPolicyRaw.storeCommand === 'string' && memoryPolicyRaw.storeCommand.trim()
|
||||
? resolveNodeCommand(memoryPolicyRaw.storeCommand.trim())
|
||||
: null,
|
||||
retrieveSuccessMarkers: normalizeStringList(memoryPolicyRaw.retrieveSuccessMarkers, ['MEMORY_RETRIEVED']),
|
||||
storeSuccessMarkers: normalizeStringList(memoryPolicyRaw.storeSuccessMarkers, ['MEMORY_STORED']),
|
||||
retrieveArtifacts: normalizeStringList(memoryPolicyRaw.retrieveArtifacts),
|
||||
storeArtifacts: normalizeStringList(memoryPolicyRaw.storeArtifacts),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import {
|
||||
runCommand,
|
||||
parseJsonFromOutput,
|
||||
parseJsonEventsFromOutput,
|
||||
excerptText,
|
||||
formatDurationMs,
|
||||
getRunDateKey,
|
||||
sleep,
|
||||
} from './scheduler-utils.mjs';
|
||||
|
||||
const LOCK_INCIDENT_LINK = 'docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md';
|
||||
|
||||
export function classifyLockBackendError(outputText) {
|
||||
const text = String(outputText || '').toLowerCase();
|
||||
if (!text.trim()) return 'unknown_backend_error';
|
||||
|
||||
const errorCategoryMatch = text.match(/error_category=([a-z0-9_]+)/);
|
||||
if (errorCategoryMatch?.[1]) {
|
||||
return errorCategoryMatch[1];
|
||||
}
|
||||
|
||||
if ((text.includes('relay') || text.includes('query')) && text.includes('timeout')) {
|
||||
return 'relay_query_timeout';
|
||||
}
|
||||
|
||||
if (text.includes('publish failed to all relays') || text.includes('failed to publish to any relay')) {
|
||||
return 'relay_publish_quorum_failure';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('connection refused')
|
||||
|| text.includes('econnrefused')
|
||||
|| text.includes('getaddrinfo')
|
||||
|| text.includes('enotfound')
|
||||
|| text.includes('eai_again')
|
||||
|| (text.includes('websocket') && text.includes('dns'))
|
||||
) {
|
||||
return 'websocket_connection_refused_or_dns';
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes('invalid url')
|
||||
|| text.includes('malformed')
|
||||
|| text.includes('unsupported protocol')
|
||||
|| text.includes('must start with ws')
|
||||
|| text.includes('invalid relay')
|
||||
) {
|
||||
return 'malformed_relay_url_config';
|
||||
}
|
||||
|
||||
return 'unknown_backend_error';
|
||||
}
|
||||
|
||||
export function buildLockBackendRemediation({ cadence, retryWindowMs, maxDeferrals, incidentSignalId = null }) {
|
||||
const steps = [
|
||||
'Likely backend/relay connectivity issue during lock acquisition.',
|
||||
`Retry window: ${formatDurationMs(retryWindowMs)} (max deferrals: ${maxDeferrals}).`,
|
||||
`Retry command: npm run scheduler:${cadence}`,
|
||||
`Run health check: npm run lock:health -- --cadence ${cadence}`,
|
||||
`Review incident runbook: ${LOCK_INCIDENT_LINK}`,
|
||||
];
|
||||
if (incidentSignalId) {
|
||||
steps.push(`Incident signal: ${incidentSignalId}`);
|
||||
}
|
||||
return `Recommended auto-remediation: ${steps.join(' ')}`;
|
||||
}
|
||||
|
||||
export async function runLockHealthPreflight({ cadence, platform }) {
|
||||
const preflight = await runCommand('npm', ['run', 'lock:health', '--', '--cadence', cadence], {
|
||||
env: { AGENT_PLATFORM: platform },
|
||||
});
|
||||
const payload = parseJsonFromOutput(`${preflight.stdout}\n${preflight.stderr}`) || {};
|
||||
const relayList = Array.isArray(payload.relays)
|
||||
? payload.relays.filter((relay) => typeof relay === 'string' && relay.trim()).map((relay) => relay.trim())
|
||||
: [];
|
||||
const combinedOutput = `${preflight.stderr}\n${preflight.stdout}`;
|
||||
const failureCategory = payload.failureCategory || classifyLockBackendError(combinedOutput);
|
||||
|
||||
return {
|
||||
code: preflight.code,
|
||||
payload,
|
||||
relayList,
|
||||
failureCategory,
|
||||
stderrExcerpt: excerptText(preflight.stderr),
|
||||
stdoutExcerpt: excerptText(preflight.stdout),
|
||||
};
|
||||
}
|
||||
|
||||
export async function acquireLockWithRetry({ selectedAgent, cadence, platform, model, lockRetry, idempotencyKey }) {
|
||||
const lockCommandArgs = ['run', 'lock:lock', '--', '--agent', selectedAgent, '--cadence', cadence];
|
||||
if (model) {
|
||||
lockCommandArgs.push('--model', model);
|
||||
}
|
||||
const backoffScheduleMs = [];
|
||||
let attempts = 0;
|
||||
const correlationId = idempotencyKey || `${cadence}:${selectedAgent}:${getRunDateKey()}`;
|
||||
|
||||
while (true) {
|
||||
attempts += 1;
|
||||
const result = await runCommand('npm', lockCommandArgs, {
|
||||
env: {
|
||||
AGENT_PLATFORM: platform,
|
||||
...(model ? { AGENT_MODEL: model } : {}),
|
||||
...(idempotencyKey ? { SCHEDULER_LOCK_IDEMPOTENCY_KEY: idempotencyKey } : {}),
|
||||
SCHEDULER_LOCK_CORRELATION_ID: correlationId,
|
||||
SCHEDULER_LOCK_ATTEMPT_ID: String(attempts),
|
||||
},
|
||||
});
|
||||
|
||||
if (result.code !== 2) {
|
||||
return { result, attempts, backoffScheduleMs, correlationId };
|
||||
}
|
||||
|
||||
if (attempts > lockRetry.maxRetries) {
|
||||
return {
|
||||
result,
|
||||
attempts,
|
||||
backoffScheduleMs,
|
||||
correlationId,
|
||||
finalBackendCategory: classifyLockBackendError(`${result.stderr}\n${result.stdout}`),
|
||||
};
|
||||
}
|
||||
|
||||
const exponentialBase = lockRetry.backoffMs * (2 ** (attempts - 1));
|
||||
const jitter = lockRetry.jitterMs > 0
|
||||
? Math.floor(Math.random() * (lockRetry.jitterMs + 1))
|
||||
: 0;
|
||||
const delayMs = exponentialBase + jitter;
|
||||
backoffScheduleMs.push(delayMs);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: 'scheduler.lock.retry',
|
||||
attempt: attempts,
|
||||
max_retries: lockRetry.maxRetries,
|
||||
delay_ms: delayMs,
|
||||
correlation_id: correlationId,
|
||||
selected_agent: selectedAgent,
|
||||
cadence,
|
||||
}));
|
||||
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeLockFailureReasons(outputText) {
|
||||
const events = parseJsonEventsFromOutput(outputText);
|
||||
const quorumFailure = events.findLast((entry) => entry.event === 'lock_publish_quorum_failed');
|
||||
if (quorumFailure && quorumFailure.reasonDistribution && typeof quorumFailure.reasonDistribution === 'object') {
|
||||
return {
|
||||
reasonDistribution: quorumFailure.reasonDistribution,
|
||||
attemptId: quorumFailure.attemptId || null,
|
||||
correlationId: quorumFailure.correlationId || null,
|
||||
totalElapsedMs: quorumFailure.totalElapsedMs ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const distribution = {};
|
||||
for (const entry of events) {
|
||||
if (entry.event !== 'lock_publish_failure') continue;
|
||||
const reason = typeof entry.reason === 'string' && entry.reason.trim()
|
||||
? entry.reason.trim()
|
||||
: 'unknown';
|
||||
distribution[reason] = (distribution[reason] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
reasonDistribution: distribution,
|
||||
attemptId: null,
|
||||
correlationId: null,
|
||||
totalElapsedMs: null,
|
||||
};
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { toYamlScalar } from './scheduler-utils.mjs';
|
||||
|
||||
/**
|
||||
* Returns an ISO 8601 timestamp string safe for use as a filename component.
|
||||
* Colons are replaced with dashes and milliseconds are stripped.
|
||||
* Example output: `2026-02-20T07-00-00Z`
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function ts() {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a metadata object that includes a standardized `failure_category` field.
|
||||
* Used by writeLog() to attach structured failure classification to task logs.
|
||||
*
|
||||
* @param {string} failureCategory - One of FAILURE_CATEGORY values (e.g. 'lock_backend_error').
|
||||
* @param {Object} [extraMetadata={}] - Additional key/value pairs to merge.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function categorizeFailureMetadata(failureCategory, extraMetadata = {}) {
|
||||
return {
|
||||
failure_category: failureCategory,
|
||||
...extraMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a task log file to `task-logs/<cadence>/` with YAML frontmatter.
|
||||
* File naming: `<ts>__<agent>__<status>.md`
|
||||
*
|
||||
* The frontmatter includes cadence, agent, status, reason, created_at, platform,
|
||||
* and any additional metadata fields. The file body repeats key fields as bullets
|
||||
* for human readability.
|
||||
*
|
||||
* These files are read by getLatestFile(), cmdCheck(), and the dashboard.
|
||||
*
|
||||
* @param {{ cadence: string, agent: string, status: string, reason: string,
|
||||
* detail?: string, platform?: string, metadata?: Object }} params
|
||||
* @returns {Promise<string>} The filename (not full path) of the written log.
|
||||
*/
|
||||
export async function writeLog({ cadence, agent, status, reason, detail, platform, metadata = {} }) {
|
||||
const logDir = path.resolve(process.cwd(), 'task-logs', cadence);
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
const file = `${ts()}__${agent}__${status}.md`;
|
||||
const mergedMetadata = {
|
||||
platform: platform || process.env.AGENT_PLATFORM || 'unknown',
|
||||
...metadata,
|
||||
};
|
||||
|
||||
const body = [
|
||||
'---',
|
||||
`cadence: ${cadence}`,
|
||||
`agent: ${agent}`,
|
||||
`status: ${status}`,
|
||||
`reason: ${toYamlScalar(reason)}`,
|
||||
detail ? `detail: ${toYamlScalar(detail)}` : null,
|
||||
`created_at: ${new Date().toISOString()}`,
|
||||
`timestamp: ${new Date().toISOString()}`,
|
||||
...Object.entries(mergedMetadata)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => `${key}: ${toYamlScalar(value)}`),
|
||||
'---',
|
||||
'',
|
||||
`# Scheduler ${status}`,
|
||||
'',
|
||||
`- reason: ${reason}`,
|
||||
detail ? `- detail: ${detail}` : null,
|
||||
...Object.entries(mergedMetadata)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => `- ${key}: ${value}`),
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
await fs.writeFile(path.join(logDir, file), body, 'utf8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a human-readable run summary to stdout.
|
||||
* Includes status, agent, prompt path, platform, reason, and the contents of
|
||||
* the memory update file (up to 2000 characters, truncated if longer).
|
||||
*
|
||||
* @param {{ status: string, agent: string, promptPath: string|null, reason: string,
|
||||
* detail?: string, memoryFile?: string, platform: string }} params
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function printRunSummary({ status, agent, promptPath, reason, detail, memoryFile, platform }) {
|
||||
let learnings = 'No learnings recorded.';
|
||||
if (memoryFile) {
|
||||
try {
|
||||
const content = await fs.readFile(memoryFile, 'utf8');
|
||||
if (content.trim()) {
|
||||
learnings = content.trim();
|
||||
if (learnings.length > 2000) {
|
||||
learnings = learnings.slice(0, 2000) + '\n... (truncated)';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write('\n================================================================================\n');
|
||||
process.stdout.write('Scheduler Run Summary\n');
|
||||
process.stdout.write('================================================================================\n');
|
||||
process.stdout.write(`Status: ${status}\n`);
|
||||
process.stdout.write(`Agent: ${agent}\n`);
|
||||
process.stdout.write(`Prompt: ${promptPath || '(none)'}\n`);
|
||||
process.stdout.write(`Platform: ${platform}\n`);
|
||||
process.stdout.write(`Reason: ${reason}\n`);
|
||||
if (detail) {
|
||||
process.stdout.write(`Detail: ${detail}\n`);
|
||||
}
|
||||
process.stdout.write('\nLearnings / Discoveries:\n');
|
||||
process.stdout.write(`${learnings}\n`);
|
||||
process.stdout.write('================================================================================\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the run summary then terminates the process with the given exit code.
|
||||
* All normal and failure exit paths in main() go through this function to ensure
|
||||
* a summary is always printed before exit.
|
||||
*
|
||||
* @param {number} code - Exit code (0 = success, 1 = general failure, 2 = backend error).
|
||||
* @param {Object|null} summaryData - Data passed to printRunSummary, or null to skip.
|
||||
* @returns {Promise<never>}
|
||||
*/
|
||||
export async function exitWithSummary(code, summaryData) {
|
||||
if (summaryData) {
|
||||
await printRunSummary(summaryData);
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readJson, getRunDateKey } from './scheduler-utils.mjs';
|
||||
|
||||
/**
|
||||
* Reads persisted scheduler run-state for the current UTC day.
|
||||
* Run-state is used to track lock deferral metadata across multiple invocations
|
||||
* within the same day (e.g., retry window tracking for non-strict lock failures).
|
||||
*
|
||||
* If the state file is missing, unreadable, or belongs to a previous day, returns
|
||||
* a fresh default state. State is keyed by `run_date` (YYYY-MM-DD UTC).
|
||||
*
|
||||
* @param {string} cadence - 'daily'|'weekly'
|
||||
* @returns {Promise<{ statePath: string, state: { run_date: string, lock_deferral: Object|null } }>}
|
||||
*/
|
||||
export async function readRunState(cadence) {
|
||||
const statePath = path.resolve(process.cwd(), 'task-logs', cadence, '.scheduler-run-state.json');
|
||||
const fallback = { run_date: getRunDateKey(), lock_deferral: null };
|
||||
const raw = await readJson(statePath, fallback);
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { statePath, state: fallback };
|
||||
}
|
||||
|
||||
if (raw.run_date !== getRunDateKey()) {
|
||||
return { statePath, state: fallback };
|
||||
}
|
||||
|
||||
return {
|
||||
statePath,
|
||||
state: {
|
||||
run_date: raw.run_date,
|
||||
lock_deferral: raw.lock_deferral && typeof raw.lock_deferral === 'object' ? raw.lock_deferral : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists scheduler run-state to disk (JSON file).
|
||||
* Creates parent directories if they do not exist.
|
||||
*
|
||||
* @param {string} statePath - Absolute path to the state file.
|
||||
* @param {Object} state - State object to serialize.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeRunState(statePath, state) {
|
||||
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique idempotency key for a deferred lock attempt.
|
||||
* The key is stable across retries for the same agent/cadence/date combination
|
||||
* by being stored in run-state and reused on subsequent invocations.
|
||||
*
|
||||
* @param {{ cadence: string, selectedAgent: string, runDate: string }} params
|
||||
* @returns {string}
|
||||
*/
|
||||
export function createIdempotencyKey({ cadence, selectedAgent, runDate }) {
|
||||
return `${cadence}:${selectedAgent}:${runDate}:${randomUUID()}`;
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const ALLOWED_ENV_KEYS = new Set([
|
||||
'PATH', 'Path', // Windows compatibility
|
||||
'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TZ',
|
||||
'NODE_ENV', 'NODE_OPTIONS', 'NODE_PATH',
|
||||
'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'SystemRoot', 'windir', 'ComSpec', 'PATHEXT',
|
||||
'TMPDIR', 'TEMP', 'TMP',
|
||||
'EDITOR', 'VISUAL',
|
||||
// CI variables
|
||||
'CI', 'GITHUB_ACTIONS', 'GITHUB_REF', 'GITHUB_HEAD_REF', 'GITHUB_BASE_REF', 'GITHUB_EVENT_NAME', 'GITHUB_SHA',
|
||||
]);
|
||||
|
||||
export function getSafeEnv() {
|
||||
const safeEnv = {};
|
||||
for (const key in process.env) {
|
||||
if (ALLOWED_ENV_KEYS.has(key) ||
|
||||
key.startsWith('npm_') ||
|
||||
key.startsWith('NOSTR_') ||
|
||||
key.startsWith('TORCH_') ||
|
||||
key.startsWith('SCHEDULER_') ||
|
||||
key.startsWith('AGENT_') ||
|
||||
key.startsWith('JULES_') ||
|
||||
key.startsWith('CODEX_') ||
|
||||
key.startsWith('CLAUDE_') ||
|
||||
key.startsWith('ANTHROPIC_') ||
|
||||
key.startsWith('GOOSE_')) {
|
||||
safeEnv[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
return safeEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a child process with a sanitized environment by default.
|
||||
* @param {string} command
|
||||
* @param {string[]} args
|
||||
* @param {object} options
|
||||
* @param {object} [options.env] - Additional environment variables to merge.
|
||||
* @param {boolean} [options.inheritProcessEnv=false] - If true, use full process.env. If false (default), use sanitized env.
|
||||
*/
|
||||
export async function runCommand(command, args = [], options = {}) {
|
||||
const baseEnv = options.inheritProcessEnv ? process.env : getSafeEnv();
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...baseEnv, ...(options.env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const OUTPUT_LIMIT = 20000; // 20KB
|
||||
let stdoutWritten = 0;
|
||||
let stderrWritten = 0;
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
|
||||
if (stdoutWritten < OUTPUT_LIMIT) {
|
||||
const remaining = OUTPUT_LIMIT - stdoutWritten;
|
||||
if (text.length <= remaining) {
|
||||
process.stdout.write(text);
|
||||
stdoutWritten += text.length;
|
||||
} else {
|
||||
process.stdout.write(text.slice(0, remaining));
|
||||
stdoutWritten += remaining;
|
||||
}
|
||||
} else if (!stdoutTruncated) {
|
||||
process.stdout.write('\n...[stdout truncated]...\n');
|
||||
stdoutTruncated = true;
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
|
||||
if (stderrWritten < OUTPUT_LIMIT) {
|
||||
const remaining = OUTPUT_LIMIT - stderrWritten;
|
||||
if (text.length <= remaining) {
|
||||
process.stderr.write(text);
|
||||
stderrWritten += text.length;
|
||||
} else {
|
||||
process.stderr.write(text.slice(0, remaining));
|
||||
stderrWritten += remaining;
|
||||
}
|
||||
} else if (!stderrTruncated) {
|
||||
process.stderr.write('\n...[stderr truncated]...\n');
|
||||
stderrTruncated = true;
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseJsonFromOutput(text) {
|
||||
const trimmed = String(text || '').trim();
|
||||
if (!trimmed) return null;
|
||||
const lines = trimmed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const line = lines[i];
|
||||
if (!line.startsWith('{') && !line.startsWith('[')) continue;
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
// Keep scanning from the end.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseJsonEventsFromOutput(text) {
|
||||
const events = [];
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
events.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON lines.
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function readJson(filePath, fallback) {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeStringList(value, fallback = []) {
|
||||
if (!Array.isArray(value)) return fallback;
|
||||
return value.filter((item) => typeof item === 'string' && item.trim()).map((item) => item.trim());
|
||||
}
|
||||
|
||||
export function parseNonNegativeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseBooleanFlag(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function redactSensitive(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/\b(BEARER\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]')
|
||||
.replace(/\b(token|api[_-]?key|secret(?:[_-]?key)?|password|passwd|authorization)\s*[:=]\s*[^\s,;]+/gi, '$1=[REDACTED]')
|
||||
.replace(/\b(sk|pk|ghp|xoxb|xoxp)_[A-Za-z0-9_-]+\b/g, '[REDACTED]');
|
||||
}
|
||||
|
||||
export function excerptText(text, maxChars = 600) {
|
||||
const clean = redactSensitive(String(text || '').trim());
|
||||
if (!clean) return '';
|
||||
if (clean.length <= maxChars) return clean;
|
||||
return `${clean.slice(0, maxChars)}…`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
||||
const minutes = Math.round(ms / 60000);
|
||||
return `${minutes} minute(s)`;
|
||||
}
|
||||
|
||||
export function getRunDateKey(now = new Date()) {
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function toYamlScalar(value) {
|
||||
const str = String(value ?? '');
|
||||
return `'${str.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Time-window guard helpers
|
||||
// Exported so they can be unit-tested independently of run-scheduler-cycle.mjs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Window sizes for the per-cadence duplicate-run guard. */
|
||||
export const CADENCE_WINDOW_MS = {
|
||||
daily: 24 * 60 * 60 * 1000, // 24 hours
|
||||
weekly: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
};
|
||||
|
||||
/** Parse a date string to milliseconds, or null if invalid. */
|
||||
export function parseDateValue(value) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
/** Returns true when a filename matches the canonical scheduler log pattern. */
|
||||
export function isStrictSchedulerLogFilename(filename) {
|
||||
return /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)__[^_]+__(completed|failed)\.md$/.test(String(filename));
|
||||
}
|
||||
|
||||
/** Extract agent name from a canonical log filename, or null. */
|
||||
export function parseAgentFromFilename(filename) {
|
||||
const match = String(filename).match(/^.+__([^_]+?)__(completed|failed)\.md$/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
/** Extract the ISO timestamp embedded in a canonical log filename as ms, or null. */
|
||||
export function parseTimestampFromFilename(filename) {
|
||||
const match = String(filename).match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)__[^_]+__(completed|failed)\.md$/);
|
||||
if (!match?.[1]) return null;
|
||||
const iso = match[1].replace(/T(\d{2})-(\d{2})-(\d{2})Z$/, 'T$1:$2:$3Z');
|
||||
return parseDateValue(iso);
|
||||
}
|
||||
|
||||
/** Read a single YAML frontmatter key from a markdown string, or null. */
|
||||
export function parseFrontmatterValue(markdown, key) {
|
||||
const lines = String(markdown).split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== '---') return null;
|
||||
const keyPattern = new RegExp(`^${key}\\s*:\\s*(.+)$`, 'i');
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '---') break;
|
||||
const match = line.match(keyPattern);
|
||||
if (match?.[1]) {
|
||||
return match[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read `created_at` from YAML frontmatter. */
|
||||
export function parseFrontmatterCreatedAt(markdown) {
|
||||
return parseFrontmatterValue(markdown, 'created_at');
|
||||
}
|
||||
|
||||
/** Read `agent` from YAML frontmatter. */
|
||||
export function parseFrontmatterAgent(markdown) {
|
||||
return parseFrontmatterValue(markdown, 'agent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans task-log files for a cadence and returns a Set of agent names that
|
||||
* completed or failed within the cadence's time window (24 h daily, 7 d weekly).
|
||||
*
|
||||
* This is the local-filesystem companion to the Nostr relay lock check. It
|
||||
* catches the cross-midnight edge case where the relay's date-scoped lock has
|
||||
* rolled over but the agent ran less than a full window ago.
|
||||
*
|
||||
* Only `completed` and `failed` logs are considered. `deferred` logs are
|
||||
* intentionally skipped so the scheduler retries lock-backend failures.
|
||||
*
|
||||
* Non-fatal: if the log directory cannot be read, an empty Set is returned.
|
||||
*
|
||||
* @param {string} logDir - Absolute or relative path to task-logs/<cadence>/.
|
||||
* @param {'daily'|'weekly'} cadence
|
||||
* @param {number} [now] - Current epoch ms (injectable for tests).
|
||||
* @returns {Promise<Set<string>>} Agent names that ran within the window.
|
||||
*/
|
||||
export async function buildRecentlyRunExclusionSet(logDir, cadence, now = Date.now()) {
|
||||
const windowMs = CADENCE_WINDOW_MS[cadence] ?? CADENCE_WINDOW_MS.daily;
|
||||
const cutoff = now - windowMs;
|
||||
const recentlyRun = new Set();
|
||||
|
||||
let entries;
|
||||
try {
|
||||
await fs.mkdir(logDir, { recursive: true });
|
||||
entries = await fs.readdir(logDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return recentlyRun;
|
||||
}
|
||||
|
||||
const logFiles = entries
|
||||
.filter((e) => e.isFile() && isStrictSchedulerLogFilename(e.name))
|
||||
.map((e) => e.name);
|
||||
|
||||
await Promise.all(logFiles.map(async (filename) => {
|
||||
const agentName = parseAgentFromFilename(filename);
|
||||
if (!agentName) return;
|
||||
|
||||
// Prefer frontmatter created_at (more precise) over the filename timestamp.
|
||||
let effectiveMs = parseTimestampFromFilename(filename);
|
||||
try {
|
||||
const content = await fs.readFile(path.join(logDir, filename), 'utf8');
|
||||
const frontmatterMs = parseDateValue(parseFrontmatterCreatedAt(content));
|
||||
if (frontmatterMs !== null) effectiveMs = frontmatterMs;
|
||||
} catch {
|
||||
// Read failure: fall back to filename timestamp.
|
||||
}
|
||||
|
||||
if (effectiveMs !== null && effectiveMs >= cutoff) {
|
||||
recentlyRun.add(agentName);
|
||||
}
|
||||
}));
|
||||
|
||||
return recentlyRun;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import re
|
||||
|
||||
def get_latest_log_file(log_dir):
|
||||
files = glob.glob(os.path.join(log_dir, "*.md"))
|
||||
if not files:
|
||||
return None
|
||||
return max(files, key=os.path.getmtime)
|
||||
|
||||
def get_previous_agent(latest_file):
|
||||
if not latest_file:
|
||||
return None
|
||||
|
||||
# Try parsing frontmatter
|
||||
try:
|
||||
with open(latest_file, 'r') as f:
|
||||
content = f.read()
|
||||
# Simple frontmatter parser
|
||||
match = re.search(r'^agent:\s*(.+)$', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback to filename
|
||||
filename = os.path.basename(latest_file)
|
||||
parts = filename.split('__')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
|
||||
return None
|
||||
|
||||
def main():
|
||||
roster_path = 'src/prompts/roster.json'
|
||||
log_dir = 'task-logs/daily/'
|
||||
config_path = 'torch-config.json'
|
||||
|
||||
# Get excluded agents from stdin (JSON)
|
||||
excluded = set()
|
||||
try:
|
||||
input_data = sys.stdin.read()
|
||||
|
||||
# Skip potential npm noise/headers before the JSON object
|
||||
json_start = input_data.find('{')
|
||||
if json_start != -1:
|
||||
input_data = input_data[json_start:]
|
||||
lock_data = json.loads(input_data)
|
||||
excluded = set(lock_data.get('excluded', []))
|
||||
# Also add currently locked/completed to exclusion if not already
|
||||
excluded.update(lock_data.get('locked', []))
|
||||
excluded.update(lock_data.get('completed', []))
|
||||
|
||||
# Also consider explicit "paused" list if present
|
||||
excluded.update(lock_data.get('paused', []))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Warning: Failed to parse input JSON: {e}\n")
|
||||
|
||||
with open(roster_path, 'r') as f:
|
||||
roster_data = json.load(f)
|
||||
roster = roster_data.get('daily', [])
|
||||
|
||||
if not roster:
|
||||
print("Error: Empty roster")
|
||||
sys.exit(1)
|
||||
|
||||
latest_file = get_latest_log_file(log_dir)
|
||||
previous_agent = get_previous_agent(latest_file)
|
||||
|
||||
start_index = 0
|
||||
if previous_agent and previous_agent in roster:
|
||||
start_index = (roster.index(previous_agent) + 1) % len(roster)
|
||||
else:
|
||||
# First run fallback
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
first_agent = config.get('scheduler', {}).get('firstPromptByCadence', {}).get('daily')
|
||||
if first_agent and first_agent in roster:
|
||||
start_index = roster.index(first_agent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Round-robin selection
|
||||
for i in range(len(roster)):
|
||||
idx = (start_index + i) % len(roster)
|
||||
agent = roster[idx]
|
||||
if agent not in excluded:
|
||||
print(agent)
|
||||
return
|
||||
|
||||
print("All roster tasks currently claimed by other agents")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,28 +0,0 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
|
||||
const server = http.createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
const type = data[0];
|
||||
if (type === 'REQ') {
|
||||
const subId = data[1];
|
||||
ws.send(JSON.stringify(['EOSE', subId]));
|
||||
} else if (type === 'EVENT') {
|
||||
const event = data[1];
|
||||
ws.send(JSON.stringify(['OK', event.id, true, '']));
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
console.log(`ws://127.0.0.1:${address.port}`);
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Relay, useWebSocketImplementation } from 'nostr-tools/relay';
|
||||
import { generateSecretKey, finalizeEvent, getPublicKey } from 'nostr-tools/pure';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
// --- Configuration ---
|
||||
const ARGS = process.argv.slice(2);
|
||||
const getArg = (key) => {
|
||||
const idx = ARGS.findIndex(a => a.startsWith(`--${key}=`));
|
||||
if (idx !== -1) return ARGS[idx].split('=')[1];
|
||||
const flagIdx = ARGS.indexOf(`--${key}`);
|
||||
if (flagIdx !== -1 && ARGS[flagIdx + 1] && !ARGS[flagIdx + 1].startsWith('--')) return ARGS[flagIdx + 1];
|
||||
return null;
|
||||
};
|
||||
const RELAYS = (getArg('relays') || process.env.RELAY_URLS || '').split(',').filter(Boolean);
|
||||
const SERVE_CMD = getArg('serve') || 'npx';
|
||||
const OUT_DIR = getArg('out') || 'artifacts';
|
||||
|
||||
// --- Logging ---
|
||||
const LOG_FILE = path.join(OUT_DIR, `smoke-${new Date().toISOString().replace(/[:.]/g, '-')}.log`);
|
||||
const JSON_FILE = path.join(OUT_DIR, `smoke-${new Date().toISOString().split('T')[0]}.json`);
|
||||
|
||||
if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
function log(msg) {
|
||||
const ts = new Date().toISOString();
|
||||
const line = `[${ts}] ${msg}`;
|
||||
console.log(line);
|
||||
fs.appendFileSync(LOG_FILE, line + '\n');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitForServer(url, timeoutMs = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return true;
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
async function main() {
|
||||
log('Starting Smoke Test Agent...');
|
||||
log(`Config: RELAYS=${RELAYS.join(',')}, SERVE=${SERVE_CMD}, OUT=${OUT_DIR}`);
|
||||
|
||||
if (RELAYS.length === 0) {
|
||||
log('ERROR: No relays specified. Use --relays or RELAY_URLS.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
steps: [],
|
||||
success: false
|
||||
};
|
||||
|
||||
let serverProcess = null;
|
||||
|
||||
try {
|
||||
// 1. Start Server
|
||||
if (SERVE_CMD !== 'none') {
|
||||
log('Step 1: Starting local server...');
|
||||
const port = 8000;
|
||||
if (SERVE_CMD === 'npx') {
|
||||
serverProcess = spawn('npx', ['serve', 'dist', '-p', String(port)], { stdio: 'ignore', shell: true });
|
||||
} else if (SERVE_CMD === 'python') {
|
||||
serverProcess = spawn('python3', ['-m', 'http.server', String(port), '-d', 'dist'], { stdio: 'ignore', shell: true });
|
||||
}
|
||||
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
log(`Waiting for server at ${serverUrl}...`);
|
||||
const ready = await waitForServer(serverUrl);
|
||||
if (!ready) {
|
||||
throw new Error('Server failed to start within timeout');
|
||||
}
|
||||
log('Server is ready.');
|
||||
results.steps.push({ name: 'start-server', status: 'ok', details: `Started ${SERVE_CMD} on port ${port}` });
|
||||
} else {
|
||||
log('Step 1: Skipping server start (none selected).');
|
||||
results.steps.push({ name: 'start-server', status: 'skipped' });
|
||||
}
|
||||
|
||||
// 2. Connect to Relay
|
||||
log(`Step 2: Connecting to relay ${RELAYS[0]}...`);
|
||||
const relay = await Relay.connect(RELAYS[0]);
|
||||
log(`Connected to ${RELAYS[0]}`);
|
||||
results.steps.push({ name: 'connect-relay', status: 'ok', details: RELAYS[0] });
|
||||
|
||||
// 3. Generate Identity
|
||||
log('Step 3: Generating ephemeral identity...');
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
log(`Pubkey: ${pk}`);
|
||||
results.steps.push({ name: 'generate-identity', status: 'ok', details: pk });
|
||||
|
||||
// 4. Publish Event
|
||||
log('Step 4: Publishing smoke test event...');
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['t', 'smoke-test']],
|
||||
content: `Smoke test run at ${new Date().toISOString()}`,
|
||||
};
|
||||
const event = finalizeEvent(eventTemplate, sk);
|
||||
await relay.publish(event);
|
||||
log(`Published event: ${event.id}`);
|
||||
results.steps.push({ name: 'publish-event', status: 'ok', details: event.id });
|
||||
|
||||
// 5. Verify Event
|
||||
log('Step 5: Verifying event read-back...');
|
||||
const sub = relay.subscribe([
|
||||
{ ids: [event.id] },
|
||||
], {
|
||||
onevent(evt) {
|
||||
log(`Received event: ${evt.id}`);
|
||||
if (evt.id === event.id) {
|
||||
log('Verification SUCCESS: Event ID matches.');
|
||||
results.steps.push({ name: 'verify-event', status: 'ok', details: 'Event retrieved and matched' });
|
||||
sub.close();
|
||||
}
|
||||
},
|
||||
oneose() {
|
||||
sub.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for event
|
||||
let attempts = 0;
|
||||
let verified = false;
|
||||
while (attempts < 10) {
|
||||
await sleep(1000);
|
||||
const found = results.steps.find(s => s.name === 'verify-event');
|
||||
if (found) {
|
||||
verified = true;
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
throw new Error('Verification failed: Event not received within timeout.');
|
||||
}
|
||||
|
||||
results.success = true;
|
||||
relay.close();
|
||||
|
||||
} catch (err) {
|
||||
log(`ERROR: ${err.message}`);
|
||||
results.error = err.message;
|
||||
results.success = false;
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (serverProcess) {
|
||||
log('Stopping server...');
|
||||
serverProcess.kill();
|
||||
}
|
||||
fs.writeFileSync(JSON_FILE, JSON.stringify(results, null, 2));
|
||||
log(`Artifacts written to ${OUT_DIR}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,248 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const VALID_CADENCES = ['daily', 'weekly'];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
sinceDays: 14,
|
||||
logRoot: path.resolve(process.cwd(), 'task-logs'),
|
||||
outDir: path.resolve(process.cwd(), 'artifacts', 'lock-reliability'),
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === '--since-days') {
|
||||
options.sinceDays = Number.parseInt(argv[i + 1] || '14', 10);
|
||||
i += 1;
|
||||
} else if (token === '--log-root') {
|
||||
options.logRoot = path.resolve(process.cwd(), argv[i + 1] || 'task-logs');
|
||||
i += 1;
|
||||
} else if (token === '--out-dir') {
|
||||
options.outDir = path.resolve(process.cwd(), argv[i + 1] || path.join('artifacts', 'lock-reliability'));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.sinceDays) || options.sinceDays < 0) {
|
||||
throw new Error(`Invalid --since-days value: ${options.sinceDays}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown) {
|
||||
const text = String(markdown || '');
|
||||
if (!text.startsWith('---\n')) return {};
|
||||
const end = text.indexOf('\n---', 4);
|
||||
if (end === -1) return {};
|
||||
const frontmatter = text.slice(4, end).split(/\r?\n/);
|
||||
const result = {};
|
||||
for (const line of frontmatter) {
|
||||
const match = line.match(/^([a-zA-Z0-9_]+):\s*(.+)\s*$/);
|
||||
if (!match) continue;
|
||||
const [, key, rawValue] = match;
|
||||
result[key] = rawValue.replace(/^'/, '').replace(/'$/, '').replace(/''/g, "'");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function inc(map, key) {
|
||||
map.set(key, (map.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
function collectRelayEndpoints({ frontmatter, body }) {
|
||||
const relays = new Set();
|
||||
const relayList = frontmatter.relay_list || '';
|
||||
relayList.split(',').map((v) => v.trim()).filter(Boolean).forEach((relay) => relays.add(relay));
|
||||
|
||||
const allText = `${body}\n${Object.values(frontmatter).join('\n')}`;
|
||||
const urlMatches = allText.match(/wss?:\/\/[^\s'`),]+/g) || [];
|
||||
for (const url of urlMatches) {
|
||||
relays.add(url.trim());
|
||||
}
|
||||
|
||||
if (!relays.size) relays.add('unknown');
|
||||
return [...relays];
|
||||
}
|
||||
|
||||
function getBackendCategory({ frontmatter, body }) {
|
||||
if (frontmatter.backend_category) return frontmatter.backend_category;
|
||||
if (frontmatter.preflight_failure_category) return `preflight:${frontmatter.preflight_failure_category}`;
|
||||
|
||||
const reasonMatch = body.match(/- reason:\s*([^\n]+)/i) || body.match(/reason:\s*([^\n]+)/i);
|
||||
const reason = reasonMatch?.[1]?.trim() || '';
|
||||
|
||||
if (/lock backend/i.test(reason)) return 'lock backend error (uncategorized)';
|
||||
if (/completion publish failed/i.test(reason)) return 'completion publish failed';
|
||||
if (/preflight/i.test(reason)) return 'preflight failed (uncategorized)';
|
||||
return null;
|
||||
}
|
||||
|
||||
function toSortedObject(counterMap) {
|
||||
return Object.fromEntries([...counterMap.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
||||
}
|
||||
|
||||
function asMarkdown(report) {
|
||||
const lines = [];
|
||||
lines.push('# Lock Reliability Summary');
|
||||
lines.push('');
|
||||
lines.push(`- Generated at: ${report.generatedAt}`);
|
||||
lines.push(`- Window: last ${report.sinceDays} day(s)`);
|
||||
lines.push(`- Logs scanned: ${report.logsScanned}`);
|
||||
lines.push(`- Runs considered: ${report.runsConsidered}`);
|
||||
lines.push('');
|
||||
|
||||
lines.push('## Totals');
|
||||
lines.push('');
|
||||
lines.push(`- completed: ${report.statusCounts.completed || 0}`);
|
||||
lines.push(`- failed: ${report.statusCounts.failed || 0}`);
|
||||
lines.push('');
|
||||
|
||||
const sections = [
|
||||
['By platform', report.byPlatform],
|
||||
['By cadence', report.byCadence],
|
||||
['By backend error category', report.byBackendCategory],
|
||||
['By relay endpoint', report.byRelayEndpoint],
|
||||
];
|
||||
|
||||
for (const [title, obj] of sections) {
|
||||
lines.push(`## ${title}`);
|
||||
lines.push('');
|
||||
const entries = Object.entries(obj);
|
||||
if (!entries.length) {
|
||||
lines.push('- (none)');
|
||||
} else {
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(`- ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.recurringBackendErrors.length) {
|
||||
lines.push('## Recurring backend errors (count >= 2)');
|
||||
lines.push('');
|
||||
for (const entry of report.recurringBackendErrors) {
|
||||
lines.push(`- ${entry.category}: ${entry.count}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function readLogFile(filePath, cadence, cutoffMs) {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile() || stat.mtimeMs < cutoffMs) return null;
|
||||
|
||||
const basename = path.basename(filePath);
|
||||
const match = basename.match(/^.+__([^_]+?)__(completed|failed)\.md$/);
|
||||
if (!match) return null;
|
||||
|
||||
const body = await fs.readFile(filePath, 'utf8');
|
||||
const frontmatter = parseFrontmatter(body);
|
||||
const status = frontmatter.status || match[2];
|
||||
const platform = frontmatter.platform || 'unknown';
|
||||
const backendCategory = status === 'failed'
|
||||
? getBackendCategory({ frontmatter, body })
|
||||
: 'n/a';
|
||||
const relayEndpoints = collectRelayEndpoints({ frontmatter, body });
|
||||
|
||||
return {
|
||||
filePath,
|
||||
cadence: frontmatter.cadence || cadence,
|
||||
status,
|
||||
platform,
|
||||
backendCategory,
|
||||
relayEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const now = Date.now();
|
||||
const cutoffMs = now - (options.sinceDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const byPlatform = new Map();
|
||||
const byCadence = new Map();
|
||||
const byBackendCategory = new Map();
|
||||
const byRelayEndpoint = new Map();
|
||||
const statusCounts = new Map();
|
||||
const records = [];
|
||||
let logsScanned = 0;
|
||||
|
||||
for (const cadence of VALID_CADENCES) {
|
||||
const cadenceDir = path.join(options.logRoot, cadence);
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(cadenceDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promises = entries.map(async (entry) => {
|
||||
const fullPath = path.join(cadenceDir, entry);
|
||||
const record = await readLogFile(fullPath, cadence, cutoffMs);
|
||||
return record;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
logsScanned += entries.length;
|
||||
|
||||
for (const record of results) {
|
||||
if (!record) continue;
|
||||
|
||||
records.push(record);
|
||||
inc(statusCounts, record.status);
|
||||
inc(byPlatform, record.platform);
|
||||
inc(byCadence, record.cadence);
|
||||
if (record.status === 'failed' && record.backendCategory) {
|
||||
inc(byBackendCategory, record.backendCategory);
|
||||
}
|
||||
for (const relay of record.relayEndpoints) {
|
||||
inc(byRelayEndpoint, relay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recurringBackendErrors = [...byBackendCategory.entries()]
|
||||
.filter(([, count]) => count >= 2)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([category, count]) => ({ category, count }));
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
sinceDays: options.sinceDays,
|
||||
logRoot: options.logRoot,
|
||||
logsScanned,
|
||||
runsConsidered: records.length,
|
||||
statusCounts: toSortedObject(statusCounts),
|
||||
byPlatform: toSortedObject(byPlatform),
|
||||
byCadence: toSortedObject(byCadence),
|
||||
byBackendCategory: toSortedObject(byBackendCategory),
|
||||
byRelayEndpoint: toSortedObject(byRelayEndpoint),
|
||||
recurringBackendErrors,
|
||||
};
|
||||
|
||||
await fs.mkdir(options.outDir, { recursive: true });
|
||||
const markdown = asMarkdown(report);
|
||||
const markdownPath = path.join(options.outDir, 'lock-reliability-summary.md');
|
||||
const jsonPath = path.join(options.outDir, 'lock-reliability-summary.json');
|
||||
|
||||
await fs.writeFile(markdownPath, markdown, 'utf8');
|
||||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
markdown: path.relative(process.cwd(), markdownPath),
|
||||
json: path.relative(process.cwd(), jsonPath),
|
||||
recurringBackendErrors,
|
||||
}));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack || error?.message || String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,387 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const REQUIRED_ARTIFACTS = [
|
||||
{
|
||||
dir: 'src/context',
|
||||
expectedPrefix: 'CONTEXT_',
|
||||
structureCheck: (content) =>
|
||||
/\bgoal\b/i.test(content) && /\bscope\b/i.test(content) && /\bconstraints\b/i.test(content),
|
||||
structureHint: 'must include goal/scope/constraints',
|
||||
},
|
||||
{
|
||||
dir: 'src/todo',
|
||||
expectedPrefix: 'TODO_',
|
||||
structureCheck: (content) =>
|
||||
/\bpending(?:\s+tasks?)?\b[\s\S]{0,400}^\s*[-*]\s+/im.test(content)
|
||||
|| /\bcompleted(?:\s+tasks?)?\b[\s\S]{0,400}^\s*[-*]\s+/im.test(content),
|
||||
structureHint: 'must include at least one pending or completed item',
|
||||
},
|
||||
{
|
||||
dir: 'src/decisions',
|
||||
expectedPrefix: 'DECISIONS_',
|
||||
structureCheck: (content) => /\bdecision\b/i.test(content) && /\brationale\b/i.test(content),
|
||||
structureHint: 'must include decision + rationale',
|
||||
},
|
||||
{
|
||||
dir: 'src/test_logs',
|
||||
expectedPrefix: 'TEST_LOG_',
|
||||
structureCheck: (content) => {
|
||||
const hasCommand = /(^|\n)\s*[-*]?\s*\**command\**\s*:/im.test(content);
|
||||
const hasResult = /(^|\n)\s*[-*]?\s*\**result\**\s*:/im.test(content);
|
||||
return hasCommand && hasResult;
|
||||
},
|
||||
structureHint: 'must include command/result pairs',
|
||||
},
|
||||
];
|
||||
|
||||
const FAILURE_KEYWORD_RE = /\b(fail|failed|failure|error)\b/i;
|
||||
const STATUS_RESOLVED_RE = /\b(resolved|closed|fixed)\b/i;
|
||||
const IDENTIFIER_INLINE_PATTERN = String.raw`\[([A-Za-z0-9._-]{3,})\]`;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
since: null,
|
||||
session: null,
|
||||
checkFailureNotes: false,
|
||||
agent: null,
|
||||
cadence: null,
|
||||
promptPath: null,
|
||||
runStart: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const value = argv[i];
|
||||
if (value === '--since') {
|
||||
args.since = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--session') {
|
||||
args.session = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--check-failure-notes') {
|
||||
args.checkFailureNotes = true;
|
||||
continue;
|
||||
}
|
||||
if (value === '--agent') {
|
||||
args.agent = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--cadence') {
|
||||
args.cadence = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--prompt-path') {
|
||||
args.promptPath = argv[i + 1] || null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === '--run-start') {
|
||||
args.runStart = argv[i + 1] || null;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function toMs(iso) {
|
||||
if (!iso) return null;
|
||||
const value = Date.parse(iso);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isNotNeededNote(content) {
|
||||
return /\bnot needed\b/i.test(content);
|
||||
}
|
||||
|
||||
async function listRecentFiles(dirPath, sinceMs) {
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(dirPath, entry.name);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (sinceMs != null && stat.mtimeMs < sinceMs) continue;
|
||||
files.push({ name: entry.name, filePath, mtimeMs: stat.mtimeMs });
|
||||
}
|
||||
return files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function hasExplicitNotNeeded(files) {
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||
if (isNotNeededNote(content)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getMatchingArtifacts(files, { expectedPrefix, session }) {
|
||||
if (session) {
|
||||
return files.filter((file) => file.name.includes(session));
|
||||
}
|
||||
return files.filter((file) => file.name.startsWith(expectedPrefix));
|
||||
}
|
||||
|
||||
function findInvalidFilenameCharacters(files) {
|
||||
// Windows-invalid filename characters: <>:"/\|?*
|
||||
const invalidCharRe = /[<>:"/\\|?*]/;
|
||||
return files
|
||||
.filter((file) => invalidCharRe.test(file.name))
|
||||
.map((file) => file.name);
|
||||
}
|
||||
|
||||
async function hasValidArtifactStructure(files, artifact) {
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||
if (artifact.structureCheck(content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readMetadataValue(content, keys) {
|
||||
for (const key of keys) {
|
||||
const re = new RegExp(`^\\s*(?:[-*]\\s*)?\\**${key}\\**\\s*:\\s*(.+)$`, 'im');
|
||||
const match = content.match(re);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractIdentifiers(content) {
|
||||
const ids = new Set();
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
// Match "failure id" as a distinct word phrase (ignores "failure identifiers" or "failure IDs")
|
||||
if (/(?:issue|incident|failure)[-_\s]*id\b/i.test(line)) {
|
||||
const match = line.match(/([A-Za-z0-9._-]{3,})\s*$/);
|
||||
if (match) ids.add(match[1]);
|
||||
}
|
||||
for (const inline of line.matchAll(new RegExp(IDENTIFIER_INLINE_PATTERN, 'g'))) {
|
||||
ids.add(inline[1]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectUnresolvedKnownIssueIds(content) {
|
||||
const ids = new Set();
|
||||
const sections = content.split(/^###\s+/m).slice(1);
|
||||
for (const sectionRaw of sections) {
|
||||
const section = sectionRaw.trim();
|
||||
const statusMatch = section.match(/\*\*Status:\*\*\s*(.+)$/im);
|
||||
const status = (statusMatch?.[1] || '').trim();
|
||||
if (status && STATUS_RESOLVED_RE.test(status)) {
|
||||
continue;
|
||||
}
|
||||
for (const id of extractIdentifiers(section)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function validateArtifactMetadata(files, { agent, cadence, promptPath, runStart }) {
|
||||
const failures = [];
|
||||
const promptPathResolved = promptPath ? path.resolve(process.cwd(), promptPath) : null;
|
||||
const promptRelative = promptPathResolved ? path.relative(process.cwd(), promptPathResolved) : null;
|
||||
const promptBase = promptPathResolved ? path.basename(promptPathResolved) : null;
|
||||
|
||||
for (const file of files) {
|
||||
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||
const fileErrors = [];
|
||||
|
||||
const agentValue = readMetadataValue(content, ['agent']);
|
||||
const cadenceValue = readMetadataValue(content, ['cadence']);
|
||||
const sessionValue = readMetadataValue(content, ['session']);
|
||||
const runStartValue = readMetadataValue(content, ['run-start', 'run_start', 'run start']);
|
||||
|
||||
if (!agentValue) fileErrors.push('missing `agent:` metadata');
|
||||
if (!cadenceValue) fileErrors.push('missing `cadence:` metadata');
|
||||
if (!sessionValue && !runStartValue) fileErrors.push('missing `session:` or `run-start:` metadata');
|
||||
|
||||
if (agent && agentValue && agentValue !== agent) {
|
||||
fileErrors.push(`agent metadata "${agentValue}" does not match active SCHEDULER_AGENT "${agent}"`);
|
||||
}
|
||||
if (cadence && cadenceValue && cadenceValue !== cadence) {
|
||||
fileErrors.push(`cadence metadata "${cadenceValue}" does not match active cadence "${cadence}"`);
|
||||
}
|
||||
if (runStart && runStartValue && runStartValue !== runStart) {
|
||||
fileErrors.push(`run-start metadata "${runStartValue}" does not match active run-start "${runStart}"`);
|
||||
}
|
||||
|
||||
if (agent && !content.includes(agent)) {
|
||||
fileErrors.push(`content does not reference active SCHEDULER_AGENT "${agent}"`);
|
||||
}
|
||||
|
||||
if (promptPathResolved) {
|
||||
const referencesPrompt = [promptPathResolved, promptRelative, promptBase].filter(Boolean)
|
||||
.some((candidate) => content.includes(candidate));
|
||||
if (!referencesPrompt) {
|
||||
fileErrors.push(`content does not reference active prompt file (${promptRelative || promptPathResolved})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileErrors.length) {
|
||||
failures.push(`${path.relative(process.cwd(), file.filePath)}: ${fileErrors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function checkFailureTracking({ sinceMs }) {
|
||||
const recentTestLogs = await listRecentFiles(path.resolve(process.cwd(), 'src/test_logs'), sinceMs);
|
||||
const failureIds = new Set();
|
||||
const logsWithFailureNoIds = [];
|
||||
|
||||
for (const file of recentTestLogs) {
|
||||
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||
// Filter out passing TAP lines, comments, and YAML blocks to avoid false positives on test descriptions
|
||||
const relevantContent = content.split(/\r?\n/)
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return !/^ok\s+\d+/.test(trimmed) &&
|
||||
!trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('>') &&
|
||||
!/^(failureType|code|name|stack|operator|expected|actual):/.test(trimmed);
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
if (FAILURE_KEYWORD_RE.test(relevantContent)) {
|
||||
const ids = extractIdentifiers(content); // Extract from full content to find IDs anywhere
|
||||
if (!ids.size) {
|
||||
logsWithFailureNoIds.push(path.relative(process.cwd(), file.filePath));
|
||||
}
|
||||
for (const id of ids) failureIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!failureIds.size && !logsWithFailureNoIds.length) {
|
||||
return { ok: true, message: null };
|
||||
}
|
||||
|
||||
if (logsWithFailureNoIds.length) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Failure-related output detected in test logs without identifiers. Add Issue/Incident identifiers to: ${logsWithFailureNoIds.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
const knownIssuesPath = path.resolve(process.cwd(), 'KNOWN_ISSUES.md');
|
||||
const knownIssuesContent = await fs.readFile(knownIssuesPath, 'utf8').catch(() => '');
|
||||
const unresolvedKnownIssueIds = collectUnresolvedKnownIssueIds(knownIssuesContent);
|
||||
const incidents = await listRecentFiles(path.resolve(process.cwd(), 'docs/agent-handoffs/incidents'), sinceMs);
|
||||
|
||||
const incidentIds = new Set();
|
||||
for (const incident of incidents) {
|
||||
const incidentContent = await fs.readFile(incident.filePath, 'utf8').catch(() => '');
|
||||
for (const id of extractIdentifiers(incidentContent)) incidentIds.add(id);
|
||||
}
|
||||
|
||||
const unmatched = [...failureIds].filter((id) => !unresolvedKnownIssueIds.has(id) && !incidentIds.has(id));
|
||||
|
||||
if (!unmatched.length) {
|
||||
return { ok: true, message: null };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: `Failure identifiers found in src/test_logs are not cross-linked to unresolved KNOWN_ISSUES.md entries or new incident notes: ${unmatched.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const sinceMs = toMs(args.since);
|
||||
const activeAgent = args.agent || process.env.SCHEDULER_AGENT || null;
|
||||
const activeCadence = args.cadence || process.env.SCHEDULER_CADENCE || null;
|
||||
const activePromptPath = args.promptPath || process.env.SCHEDULER_PROMPT_PATH || null;
|
||||
const activeRunStart = args.runStart || null;
|
||||
|
||||
if (args.since && sinceMs == null) {
|
||||
console.error(`Invalid --since value: ${args.since}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (!activeAgent || !activePromptPath) {
|
||||
console.error('Missing scheduler context: provide --agent and --prompt-path (or set SCHEDULER_AGENT/SCHEDULER_PROMPT_PATH).');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
|
||||
for (const artifact of REQUIRED_ARTIFACTS) {
|
||||
const dirPath = path.resolve(process.cwd(), artifact.dir);
|
||||
const files = await listRecentFiles(dirPath, sinceMs);
|
||||
const matchingArtifacts = getMatchingArtifacts(files, {
|
||||
expectedPrefix: artifact.expectedPrefix,
|
||||
session: args.session,
|
||||
});
|
||||
|
||||
if (!matchingArtifacts.length) {
|
||||
const hasNotNeeded = await hasExplicitNotNeeded(files);
|
||||
if (hasNotNeeded) continue;
|
||||
|
||||
const expectedName = args.session
|
||||
? `${artifact.expectedPrefix}${args.session}.md`
|
||||
: `${artifact.expectedPrefix}<timestamp>.md`;
|
||||
missing.push(`${artifact.dir}/${expectedName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const invalidFilenames = findInvalidFilenameCharacters(matchingArtifacts);
|
||||
if (invalidFilenames.length) {
|
||||
missing.push(`${artifact.dir}: invalid filename characters for cross-platform sync in ${invalidFilenames.join(', ')}`);
|
||||
}
|
||||
|
||||
const hasValidStructure = await hasValidArtifactStructure(matchingArtifacts, artifact);
|
||||
if (!hasValidStructure) {
|
||||
missing.push(`${artifact.dir}: ${artifact.structureHint}`);
|
||||
}
|
||||
|
||||
const metadataFailures = await validateArtifactMetadata(matchingArtifacts, {
|
||||
agent: activeAgent,
|
||||
cadence: activeCadence,
|
||||
promptPath: activePromptPath,
|
||||
runStart: activeRunStart,
|
||||
});
|
||||
missing.push(...metadataFailures);
|
||||
}
|
||||
|
||||
if (args.checkFailureNotes) {
|
||||
const failureTracking = await checkFailureTracking({ sinceMs });
|
||||
if (!failureTracking.ok && failureTracking.message) {
|
||||
missing.push(failureTracking.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length) {
|
||||
console.error('Missing required run artifacts:');
|
||||
for (const item of missing) {
|
||||
console.error(`- ${item}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Run artifacts verified.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const PORT = 3456;
|
||||
const REQUESTS = 2000;
|
||||
const CONCURRENCY = 50;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${PORT}/dashboard/`, (res) => {
|
||||
if (res.statusCode === 200) resolve();
|
||||
else reject(new Error(`Status ${res.statusCode}`));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
return;
|
||||
} catch (_e) {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
throw new Error('Server did not start');
|
||||
}
|
||||
|
||||
function makeRequest() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Request a small file that exists
|
||||
const req = http.get(`http://localhost:${PORT}/torch-config.example.json`, (res) => {
|
||||
res.resume(); // consume body
|
||||
res.on('end', resolve);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function benchmark() {
|
||||
console.log('Starting server...');
|
||||
const serverProcess = spawn('node', ['bin/torch-lock.mjs', 'dashboard', '--port', String(PORT)], {
|
||||
stdio: 'ignore' // Suppress logs for cleaner output
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer();
|
||||
console.log('Server ready.');
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
let sent = 0;
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const current = sent++;
|
||||
if (current >= REQUESTS) break;
|
||||
try {
|
||||
await makeRequest();
|
||||
} catch (_e) {
|
||||
// console.error(_e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = [];
|
||||
for (let i = 0; i < CONCURRENCY; i++) {
|
||||
workers.push(worker());
|
||||
}
|
||||
|
||||
await Promise.all(workers);
|
||||
|
||||
const end = performance.now();
|
||||
const duration = end - start;
|
||||
const rps = (REQUESTS / duration) * 1000;
|
||||
|
||||
console.log(`Requests: ${REQUESTS}`);
|
||||
console.log(`Duration: ${duration.toFixed(2)}ms`);
|
||||
console.log(`RPS: ${rps.toFixed(2)}`);
|
||||
|
||||
} finally {
|
||||
serverProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
benchmark().catch(console.error);
|
||||
@@ -1,135 +0,0 @@
|
||||
import { createLifecyclePlan } from '../src/services/memory/pruner.js';
|
||||
import crypto from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const ITERATIONS = 3;
|
||||
const GROUPS = 100;
|
||||
const GROUP_SIZE = 5;
|
||||
const LATENCY_MS = 10;
|
||||
|
||||
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 ?? 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,
|
||||
};
|
||||
}
|
||||
|
||||
function generateMemories() {
|
||||
const memories = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (let i = 0; i < GROUPS; i++) {
|
||||
const groupId = `group-${i}`;
|
||||
for (let j = 0; j < GROUP_SIZE; j++) {
|
||||
memories.push(createMemory({
|
||||
id: `${groupId}-${j}`,
|
||||
content: `Memory ${j} for group ${groupId}`,
|
||||
summary: `Summary ${j} for group ${groupId}`,
|
||||
tags: ['benchmark', groupId],
|
||||
created_at: now - (j * 100), // Close in time
|
||||
last_seen: now,
|
||||
importance: 0.5,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Add some random noise memories that won't group
|
||||
for (let i = 0; i < 50; i++) {
|
||||
memories.push(createMemory({
|
||||
id: `noise-${i}`,
|
||||
content: `Noise memory ${i}`,
|
||||
tags: [`noise-${i}`], // Unique tags to prevent grouping
|
||||
created_at: now - (i * 100000), // Spread out
|
||||
}));
|
||||
}
|
||||
|
||||
return memories;
|
||||
}
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function runBenchmark() {
|
||||
console.log(`Setting up benchmark: ${GROUPS} groups of ${GROUP_SIZE} memories + noise.`);
|
||||
console.log(`Mock latency: ${LATENCY_MS}ms per group condensation.`);
|
||||
|
||||
const memories = generateMemories();
|
||||
|
||||
// Prepare embeddings
|
||||
const groupEmbeddings = new Map();
|
||||
for (let i = 0; i < GROUPS; i++) {
|
||||
const vec = new Array(GROUPS).fill(0);
|
||||
vec[i] = 1;
|
||||
groupEmbeddings.set(`group-${i}`, vec);
|
||||
}
|
||||
|
||||
const getEmbeddingWrapper = (memory) => {
|
||||
const groupIdTag = memory.tags.find(t => t.startsWith('group-'));
|
||||
if (groupIdTag && groupEmbeddings.has(groupIdTag)) {
|
||||
return groupEmbeddings.get(groupIdTag);
|
||||
}
|
||||
// Return a unique random vector for noise to prevent grouping
|
||||
// But since they have unique tags, getEmbedding won't even be called for similarity check
|
||||
// if tag overlap check fails first?
|
||||
// groupNearDuplicates logic:
|
||||
// ... if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue;
|
||||
// So if tags don't overlap, it continues.
|
||||
// So embedding doesn't matter for noise if tags are unique.
|
||||
return [Math.random()];
|
||||
};
|
||||
|
||||
const options = {
|
||||
retentionMs: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
duplicateWindowMs: 1000 * 60 * 60, // 1 hour
|
||||
similarityThreshold: 0.99,
|
||||
getEmbedding: getEmbeddingWrapper,
|
||||
generateSummary: async (_prompt) => {
|
||||
await delay(LATENCY_MS);
|
||||
return JSON.stringify({
|
||||
summary: 'Condensed summary based on prompt.',
|
||||
importance: 0.8,
|
||||
});
|
||||
},
|
||||
// Mock templateDir to avoid file reading issues if paths are tricky,
|
||||
// but loadMemoryPromptTemplates reads from file system.
|
||||
// It should work if run from root.
|
||||
};
|
||||
|
||||
console.log(`Starting ${ITERATIONS} iterations...`);
|
||||
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const start = performance.now();
|
||||
const plan = await createLifecyclePlan(memories, options);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Verify grouping happened
|
||||
const condensedCount = plan.condensedGroups.length;
|
||||
// We expect exactly GROUPS condensed groups.
|
||||
if (condensedCount !== GROUPS) {
|
||||
console.warn(`Warning: Expected ${GROUPS} condensed groups, but got ${condensedCount}. Check grouping logic.`);
|
||||
}
|
||||
|
||||
times.push(duration);
|
||||
console.log(`Iteration ${i + 1}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
const avg = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
console.log(`Average execution time: ${avg.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
runBenchmark().catch(console.error);
|
||||
@@ -1,106 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
const DEFAULT_MAX_LINES = 300;
|
||||
const DEFAULT_EXCLUDED_DIRS = ['node_modules', '.git', 'dist', 'artifacts', 'test_logs', 'coverage'];
|
||||
const DEFAULT_EXCLUDED_FILES = ['package-lock.json'];
|
||||
|
||||
function countLines(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return content.split('\n').length;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
'max-lines': { type: 'string' },
|
||||
'exclude-dir': { type: 'string', multiple: true },
|
||||
'exclude-file': { type: 'string', multiple: true },
|
||||
'no-defaults': { type: 'boolean' },
|
||||
'report': { type: 'boolean' },
|
||||
'help': { type: 'boolean' },
|
||||
},
|
||||
strict: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: check-file-size.mjs [options]
|
||||
|
||||
Options:
|
||||
--max-lines <n> Max lines allowed (default: ${DEFAULT_MAX_LINES})
|
||||
--exclude-dir <dir> Exclude directory (can be used multiple times)
|
||||
--exclude-file <file> Exclude file (can be used multiple times)
|
||||
--no-defaults Do not use default exclusions
|
||||
--report Report mode (exit code 0 even if violations found)
|
||||
--help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const MAX_LINES = values['max-lines'] ? parseInt(values['max-lines'], 10) : DEFAULT_MAX_LINES;
|
||||
const noDefaults = values['no-defaults'] || false;
|
||||
|
||||
const EXCLUDED_DIRS = noDefaults ? [] : [...DEFAULT_EXCLUDED_DIRS];
|
||||
if (values['exclude-dir']) {
|
||||
EXCLUDED_DIRS.push(...values['exclude-dir']);
|
||||
}
|
||||
|
||||
const EXCLUDED_FILES = noDefaults ? [] : [...DEFAULT_EXCLUDED_FILES];
|
||||
if (values['exclude-file']) {
|
||||
EXCLUDED_FILES.push(...values['exclude-file']);
|
||||
}
|
||||
|
||||
function scanDirectory(dir) {
|
||||
let files = [];
|
||||
try {
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!EXCLUDED_DIRS.includes(item)) {
|
||||
files = files.concat(scanDirectory(fullPath));
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (EXCLUDED_FILES.includes(item)) {
|
||||
continue;
|
||||
}
|
||||
if (['.js', '.mjs', '.ts', '.html', '.css', '.json', '.md'].includes(path.extname(item))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scanning ${dir}: ${error.message}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const reportMode = values.report;
|
||||
|
||||
const files = scanDirectory('.');
|
||||
let oversizedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const lines = countLines(file);
|
||||
if (lines > MAX_LINES) {
|
||||
const excess = lines - MAX_LINES;
|
||||
console.log(`${file}: ${lines} lines (excess: ${excess})`);
|
||||
oversizedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reportMode && oversizedCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,123 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
const DEFAULT_EXCLUDED_DIRS = ['node_modules', '.git', 'dist', 'artifacts', 'test_logs', 'coverage'];
|
||||
const DEFAULT_TARGET_EXTENSIONS = ['.js', '.mjs', '.ts', '.html'];
|
||||
const DEFAULT_EXCLUDED_FILES = [];
|
||||
|
||||
const BASELINE = {
|
||||
"landing/index.html": 1,
|
||||
"total": 1
|
||||
};
|
||||
|
||||
function countInnerHTML(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const regex = /\.innerHTML\s*=/g;
|
||||
const matches = content.match(regex);
|
||||
return matches ? matches.length : 0;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
'exclude-dir': { type: 'string', multiple: true },
|
||||
'exclude-file': { type: 'string', multiple: true },
|
||||
'no-defaults': { type: 'boolean' },
|
||||
'report': { type: 'boolean' },
|
||||
'update': { type: 'boolean' },
|
||||
'help': { type: 'boolean' },
|
||||
},
|
||||
strict: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
Usage: check-innerhtml.mjs [options]
|
||||
|
||||
Options:
|
||||
--exclude-dir <dir> Exclude directory (can be used multiple times)
|
||||
--exclude-file <file> Exclude file (can be used multiple times)
|
||||
--no-defaults Do not use default exclusions
|
||||
--report Report mode (exit code 0 even if violations found)
|
||||
--update Update mode (print new baseline)
|
||||
--help Show this help
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const noDefaults = values['no-defaults'] || false;
|
||||
const EXCLUDED_DIRS = noDefaults ? [] : [...DEFAULT_EXCLUDED_DIRS];
|
||||
if (values['exclude-dir']) EXCLUDED_DIRS.push(...values['exclude-dir']);
|
||||
|
||||
const EXCLUDED_FILES = noDefaults ? [] : [...DEFAULT_EXCLUDED_FILES];
|
||||
if (values['exclude-file']) EXCLUDED_FILES.push(...values['exclude-file']);
|
||||
|
||||
const TARGET_EXTENSIONS = DEFAULT_TARGET_EXTENSIONS;
|
||||
|
||||
function scanDirectory(dir) {
|
||||
let files = [];
|
||||
try {
|
||||
const items = fs.readdirSync(dir);
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!EXCLUDED_DIRS.includes(item)) {
|
||||
files = files.concat(scanDirectory(fullPath));
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
if (EXCLUDED_FILES.includes(item)) continue;
|
||||
if (TARGET_EXTENSIONS.includes(path.extname(item))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scanning ${dir}: ${error.message}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const reportMode = values.report;
|
||||
const updateMode = values.update;
|
||||
const files = scanDirectory('.');
|
||||
|
||||
let totalAssignments = 0;
|
||||
let newBaseline = {};
|
||||
|
||||
for (const file of files) {
|
||||
const count = countInnerHTML(file);
|
||||
if (count > 0) {
|
||||
if (reportMode || updateMode) {
|
||||
console.log(`${file}: ${count} assignments`);
|
||||
}
|
||||
newBaseline[file] = count;
|
||||
totalAssignments += count;
|
||||
|
||||
if (!updateMode && !reportMode) {
|
||||
if (!BASELINE[file] || count > BASELINE[file]) {
|
||||
console.error(`ERROR: ${file} has ${count} innerHTML assignments (baseline: ${BASELINE[file] || 0}).`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reportMode || updateMode) {
|
||||
console.log(`Total assignments: ${totalAssignments}`);
|
||||
}
|
||||
|
||||
if (updateMode) {
|
||||
newBaseline.total = totalAssignments;
|
||||
console.log('BASELINE =', JSON.stringify(newBaseline, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,102 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def get_all_files(extensions=['.mjs', '.js']):
|
||||
files = []
|
||||
for root, dirs, filenames in os.walk('.'):
|
||||
if 'node_modules' in root or '.git' in root or 'artifacts' in root or 'dist' in root:
|
||||
continue
|
||||
for filename in filenames:
|
||||
if any(filename.endswith(ext) for ext in extensions):
|
||||
files.append(os.path.join(root, filename))
|
||||
return files
|
||||
|
||||
def grep_file(filepath, all_files):
|
||||
filename = os.path.basename(filepath)
|
||||
name_no_ext = os.path.splitext(filename)[0]
|
||||
|
||||
# Heuristic: search for the filename or the name without extension
|
||||
# We must exclude the file itself from the search
|
||||
|
||||
# Construct grep command
|
||||
# git grep -F "string" -- .
|
||||
|
||||
# Check for full filename usage
|
||||
try:
|
||||
# We use git grep because it's fast and respects .gitignore
|
||||
# We search for the filename string.
|
||||
# We assume if the filename appears in another file, it MIGHT be used.
|
||||
# This is conservative.
|
||||
|
||||
# Search for filename (e.g. "foo.mjs")
|
||||
result_full = subprocess.run(
|
||||
['git', 'grep', '-F', filename, '--', '.'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
# Search for name without extension (e.g. "foo") if it's not "index"
|
||||
result_no_ext = None
|
||||
if name_no_ext != 'index':
|
||||
result_no_ext = subprocess.run(
|
||||
['git', 'grep', '-F', name_no_ext, '--', '.'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = result_full.stdout
|
||||
if result_no_ext:
|
||||
output += result_no_ext.stdout
|
||||
|
||||
lines = output.splitlines()
|
||||
|
||||
# Filter out self-references
|
||||
count = 0
|
||||
for line in lines:
|
||||
if line.startswith(filepath.lstrip('./')):
|
||||
continue
|
||||
count += 1
|
||||
|
||||
return count
|
||||
except Exception as e:
|
||||
print(f"Error grepping {filepath}: {e}")
|
||||
return 1 # Assume used on error
|
||||
|
||||
def main():
|
||||
files = get_all_files()
|
||||
candidates = []
|
||||
|
||||
# Known entry points or special files to exclude from "dead" list immediately
|
||||
# These might not be imported but are used by scripts or configuration
|
||||
exclusions = [
|
||||
'./bin/torch-lock.mjs',
|
||||
'./src/lib.mjs', # Main entry
|
||||
'./src/constants.mjs', # Likely used
|
||||
'./build.mjs',
|
||||
'./eslint.config.mjs',
|
||||
]
|
||||
|
||||
print(f"Scanning {len(files)} files...")
|
||||
|
||||
for f in files:
|
||||
if f in exclusions:
|
||||
continue
|
||||
|
||||
# Skip test files
|
||||
if '.test.' in f or '/test/' in f or '/tests/' in f:
|
||||
continue
|
||||
|
||||
count = grep_file(f, files)
|
||||
if count == 0:
|
||||
candidates.append(f)
|
||||
print(f"Candidate: {f}")
|
||||
|
||||
if not candidates:
|
||||
print("No candidates found.")
|
||||
else:
|
||||
print(f"Found {len(candidates)} candidates.")
|
||||
with open('artifacts/candidates.txt', 'w') as f_out:
|
||||
for c in candidates:
|
||||
f_out.write(c + '\n')
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,45 +0,0 @@
|
||||
import { listProposals, applyProposal, rejectProposal, validateProposal } from '../../src/services/governance/index.js';
|
||||
|
||||
async function main() {
|
||||
console.log('Governance: Scanning for pending proposals...');
|
||||
const proposals = await listProposals();
|
||||
const pending = proposals.filter(p => p.status === 'pending');
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('No pending proposals found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${pending.length} pending proposals.`);
|
||||
|
||||
for (const p of pending) {
|
||||
console.log(`\nProcessing proposal: ${p.id} (Target: ${p.target})`);
|
||||
|
||||
// 1. Validate
|
||||
const validation = await validateProposal(p.id);
|
||||
if (!validation.valid) {
|
||||
console.error(` Validation FAILED: ${validation.reason}`);
|
||||
await rejectProposal(p.id, validation.reason);
|
||||
console.log(` Rejected proposal ${p.id}.`);
|
||||
continue;
|
||||
}
|
||||
console.log(' Validation PASSED.');
|
||||
|
||||
// 2. Apply
|
||||
try {
|
||||
await applyProposal(p.id);
|
||||
console.log(` Applied proposal ${p.id} successfully.`);
|
||||
} catch (e) {
|
||||
console.error(` Application FAILED: ${e.message}`);
|
||||
// Maybe reject with application error?
|
||||
await rejectProposal(p.id, `Application failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nGovernance cycle complete.');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error in governance script:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { ingestEvents, getRelevantMemories } from '../../src/services/memory/index.js';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Parse environment variables
|
||||
const fallbackCadence = 'daily';
|
||||
const cadence = process.env.SCHEDULER_CADENCE || fallbackCadence;
|
||||
const agentId = process.env.SCHEDULER_AGENT || `scheduler-memory-${cadence}`;
|
||||
const promptPath = process.env.SCHEDULER_PROMPT_PATH || '';
|
||||
const runId = process.env.SCHEDULER_RUN_ID ||
|
||||
process.env.SCHEDULER_SESSION_ID ||
|
||||
process.env.RUN_ID ||
|
||||
`session-${Date.now().toString(36)}`;
|
||||
|
||||
// Extract prompt intent
|
||||
let promptIntent = `scheduler memory retrieval`;
|
||||
if (promptPath) {
|
||||
try {
|
||||
const promptRaw = readFileSync(promptPath, 'utf8');
|
||||
const promptLines = promptRaw.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
const intentLine = promptLines.find(line => line.startsWith('#') || line.toLowerCase().startsWith('goal'));
|
||||
if (intentLine) {
|
||||
promptIntent = intentLine.replace(/^#+\s*/, '');
|
||||
} else if (promptLines.length > 0) {
|
||||
promptIntent = promptLines[0];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not read prompt file: ${promptPath}`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct query
|
||||
const query = ['scheduler', cadence, 'agent', agentId, 'intent', promptIntent].join(' | ');
|
||||
const timestamp = Date.now();
|
||||
|
||||
// Create retrieval seed event
|
||||
const events = [{
|
||||
agent_id: agentId,
|
||||
content: `Memory retrieval seed for ${cadence} :: ${promptIntent}`,
|
||||
timestamp,
|
||||
tags: ['scheduler', cadence, 'retrieve'],
|
||||
metadata: {
|
||||
session_id: runId,
|
||||
source: 'scheduler-retrieve',
|
||||
importance: 0.4,
|
||||
prompt_path: promptPath
|
||||
}
|
||||
}];
|
||||
|
||||
try {
|
||||
// 1. Ingest seed event
|
||||
const ingested = await ingestEvents(events, { agent_id: agentId });
|
||||
|
||||
// 2. Retrieve memories
|
||||
const retrieved = await getRelevantMemories({ agent_id: agentId, query, k: 5 });
|
||||
|
||||
// 3. Prepare artifacts
|
||||
const sessionDir = path.join('.scheduler-memory', runId);
|
||||
const latestDir = path.join('.scheduler-memory', 'latest', cadence);
|
||||
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
mkdirSync(latestDir, { recursive: true });
|
||||
|
||||
const artifact = {
|
||||
cadence,
|
||||
operation: 'retrieve',
|
||||
runId,
|
||||
servicePath: 'src/services/memory/index.js#getRelevantMemories',
|
||||
inputs: {
|
||||
agentId,
|
||||
promptPath,
|
||||
promptIntent,
|
||||
query,
|
||||
events: events.length
|
||||
},
|
||||
outputs: {
|
||||
ingestedCount: ingested.length,
|
||||
retrievedCount: retrieved.length
|
||||
},
|
||||
status: 'ok'
|
||||
};
|
||||
|
||||
// 4. Write artifacts
|
||||
writeFileSync(path.join(sessionDir, 'retrieve.json'), JSON.stringify(artifact, null, 2));
|
||||
writeFileSync(path.join(sessionDir, 'retrieve.ok'), 'MEMORY_RETRIEVED\n');
|
||||
writeFileSync(path.join(latestDir, 'retrieve.ok'), 'MEMORY_RETRIEVED\n');
|
||||
// Format retrieved memories as readable markdown sections.
|
||||
// Prefer m.summary (compact excerpt) over m.content (full blob) for prompt injection.
|
||||
const memoriesMarkdown = retrieved.length > 0
|
||||
? retrieved.map((m, i) => {
|
||||
const date = new Date(m.created_at || m.last_seen || Date.now()).toISOString().split('T')[0];
|
||||
const tagStr = Array.isArray(m.tags) && m.tags.length > 0 ? m.tags.join(', ') : '';
|
||||
const text = (m.summary || m.content || '').trim();
|
||||
return `### ${i + 1}. [${date}]${tagStr ? ` (${tagStr})` : ''}\n\n${text}`;
|
||||
}).join('\n\n---\n\n')
|
||||
: '_No memories found for this agent. This may be the first run or the memory store is empty._';
|
||||
writeFileSync(path.join(latestDir, 'memories.md'), memoriesMarkdown);
|
||||
|
||||
// 5. Output success marker to stdout (for scheduler verification)
|
||||
console.log('MEMORY_RETRIEVED');
|
||||
} catch (error) {
|
||||
console.error('Memory retrieval failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { ingestEvents } from '../../src/services/memory/index.js';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Parse environment variables
|
||||
const fallbackCadence = 'daily';
|
||||
const cadence = process.env.SCHEDULER_CADENCE || fallbackCadence;
|
||||
const agentId = process.env.SCHEDULER_AGENT || `scheduler-memory-${cadence}`;
|
||||
const promptPath = process.env.SCHEDULER_PROMPT_PATH || '';
|
||||
const runId = process.env.SCHEDULER_RUN_ID ||
|
||||
process.env.SCHEDULER_SESSION_ID ||
|
||||
process.env.RUN_ID ||
|
||||
`session-${Date.now().toString(36)}`;
|
||||
|
||||
// Extract prompt intent
|
||||
let promptIntent = `scheduler memory store`;
|
||||
if (promptPath) {
|
||||
try {
|
||||
const promptRaw = readFileSync(promptPath, 'utf8');
|
||||
const promptLines = promptRaw.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
const intentLine = promptLines.find(line => line.startsWith('#') || line.toLowerCase().startsWith('goal'));
|
||||
if (intentLine) {
|
||||
promptIntent = intentLine.replace(/^#+\s*/, '');
|
||||
} else if (promptLines.length > 0) {
|
||||
promptIntent = promptLines[0];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not read prompt file: ${promptPath}`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for memory input file
|
||||
let memoryContent = '';
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const fileArgIndex = cliArgs.indexOf('--file');
|
||||
const explicitFile = fileArgIndex !== -1 ? cliArgs[fileArgIndex + 1] : null;
|
||||
const envFile = process.env.SCHEDULER_MEMORY_FILE;
|
||||
const targetFile = explicitFile || (envFile && existsSync(envFile) ? envFile : null);
|
||||
|
||||
if (targetFile) {
|
||||
try {
|
||||
if (existsSync(targetFile)) {
|
||||
memoryContent = readFileSync(targetFile, 'utf8').trim();
|
||||
console.log(`Loaded memory content from ${targetFile}`);
|
||||
} else {
|
||||
console.warn(`Target memory file does not exist: ${targetFile}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to read memory file ${targetFile}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const baseTs = Date.now();
|
||||
let events = [];
|
||||
|
||||
if (memoryContent) {
|
||||
events.push({
|
||||
agent_id: agentId,
|
||||
content: memoryContent,
|
||||
timestamp: baseTs,
|
||||
tags: ['scheduler', cadence, 'store', 'insight'],
|
||||
metadata: {
|
||||
session_id: runId,
|
||||
source: 'agent-output',
|
||||
importance: 0.8, // User-provided memory is important
|
||||
prompt_path: promptPath
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No memory file found — skip ingest rather than storing meaningless placeholder events.
|
||||
// Placeholder events pollute the store and degrade retrieval quality on future runs.
|
||||
console.warn(`No memory input found (checked --file and env SCHEDULER_MEMORY_FILE=${envFile}). Skipping ingest.`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Ingest events
|
||||
const stored = await ingestEvents(events, { agent_id: agentId });
|
||||
|
||||
// 2. Prepare artifacts
|
||||
const sessionDir = path.join('.scheduler-memory', runId);
|
||||
const latestDir = path.join('.scheduler-memory', 'latest', cadence);
|
||||
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
mkdirSync(latestDir, { recursive: true });
|
||||
|
||||
const artifact = {
|
||||
cadence,
|
||||
operation: 'store',
|
||||
runId,
|
||||
servicePath: 'src/services/memory/index.js#ingestEvents',
|
||||
inputs: {
|
||||
agentId,
|
||||
promptPath,
|
||||
promptIntent,
|
||||
events: events.length,
|
||||
sourceFile: targetFile || 'none'
|
||||
},
|
||||
outputs: {
|
||||
storedCount: stored.length,
|
||||
summaries: stored.map(m => m.summary)
|
||||
},
|
||||
status: 'ok'
|
||||
};
|
||||
|
||||
// 3. Write artifacts
|
||||
writeFileSync(path.join(sessionDir, 'store.json'), JSON.stringify(artifact, null, 2));
|
||||
writeFileSync(path.join(sessionDir, 'store.ok'), 'MEMORY_STORED\n');
|
||||
writeFileSync(path.join(latestDir, 'store.ok'), 'MEMORY_STORED\n');
|
||||
|
||||
// 4. Output success marker to stdout (for scheduler verification)
|
||||
console.log('MEMORY_STORED');
|
||||
} catch (error) {
|
||||
console.error('Memory storage failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const ITERATIONS = 5;
|
||||
const COMMAND = 'node';
|
||||
const ARGS = ['bin/torch-lock.mjs', 'list'];
|
||||
|
||||
async function runOnce(i) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = performance.now();
|
||||
const child = spawn(COMMAND, ARGS, { stdio: 'ignore' });
|
||||
|
||||
child.on('close', (code) => {
|
||||
const duration = performance.now() - start;
|
||||
if (code === 0) {
|
||||
console.log(`Run ${i + 1}: ${duration.toFixed(2)}ms`);
|
||||
resolve(duration);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Benchmarking "${COMMAND} ${ARGS.join(' ')}" (${ITERATIONS} iterations)...`);
|
||||
const times = [];
|
||||
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
try {
|
||||
const duration = await runOnce(i);
|
||||
times.push(duration);
|
||||
} catch (err) {
|
||||
console.error(`Run ${i + 1} failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (times.length === 0) {
|
||||
console.error('No successful runs.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sum = times.reduce((a, b) => a + b, 0);
|
||||
const avg = sum / times.length;
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
|
||||
console.log('\nResults:');
|
||||
console.log(` Count: ${times.length}`);
|
||||
console.log(` Average: ${avg.toFixed(2)}ms`);
|
||||
console.log(` Min: ${min.toFixed(2)}ms`);
|
||||
console.log(` Max: ${max.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,51 +0,0 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const patterns = [
|
||||
/setInterval|setTimeout|requestAnimationFrame|requestIdleCallback/i,
|
||||
/Promise\.allSettled|Promise\.all|Promise\.any|Promise\.race/i,
|
||||
/new Worker|Worker\(|postMessage\(|getDmDecryptWorkerQueueSize|decryptDmInWorker/i,
|
||||
/new WebTorrent|WebTorrent|torrent|magnet|torrentHash|magnetValidators/i,
|
||||
/integrationClient\.pool|publishEventToRelays|pool\.list|queueSignEvent|relayManager|authService|hydrateFromStorage/i,
|
||||
/document\.hidden|visibilitychange/i
|
||||
];
|
||||
|
||||
async function scanDir(dir) {
|
||||
const hits = [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
||||
hits.push(...await scanDir(fullPath));
|
||||
} else if (entry.isFile() && /\.(mjs|js|jsx|ts|tsx)$/.test(entry.name)) {
|
||||
const content = await fs.readFile(fullPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(line)) {
|
||||
hits.push({
|
||||
file: fullPath,
|
||||
line: index + 1,
|
||||
content: line.trim(),
|
||||
pattern: pattern.toString()
|
||||
});
|
||||
break; // One hit per line is enough
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') console.error(`Error scanning ${dir}:`, err);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const hits = await scanDir('src');
|
||||
const outputFile = process.argv[2] || 'reports/performance/hits.json';
|
||||
await fs.writeFile(outputFile, JSON.stringify(hits, null, 2));
|
||||
console.log(`Wrote ${hits.length} hits to ${outputFile}`);
|
||||
@@ -1,92 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const RUNS = 5;
|
||||
const results = {};
|
||||
const runDiagnostics = [];
|
||||
|
||||
// Parse arguments to separate our flags from test runner args
|
||||
const args = process.argv.slice(2);
|
||||
let outputDir = 'reports/test-audit';
|
||||
const testArgs = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--output-dir') {
|
||||
outputDir = args[i + 1];
|
||||
i++; // Skip next arg
|
||||
} else {
|
||||
testArgs.push(args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to npm test patterns if no args provided
|
||||
if (testArgs.length === 0) {
|
||||
testArgs.push('test/*.test.mjs', 'test/*.test.js');
|
||||
}
|
||||
|
||||
async function runTests(i) {
|
||||
console.log(`Run ${i + 1}/${RUNS}...`);
|
||||
return new Promise((resolve) => {
|
||||
const tapOutputPath = join(outputDir, `flaky-run-${String(i + 1).padStart(2, '0')}.tap`);
|
||||
const child = spawn(process.execPath, [
|
||||
'--test',
|
||||
'--test-reporter=tap',
|
||||
`--test-reporter-destination=${tapOutputPath}`,
|
||||
...testArgs
|
||||
], {
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
let observed = 0;
|
||||
const tapOutput = existsSync(tapOutputPath) ? readFileSync(tapOutputPath, 'utf8') : '';
|
||||
const lines = tapOutput.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('not ok ') && !line.includes('# skip') && !line.includes('# todo')) {
|
||||
const name = line.substring(line.indexOf('-') + 1).trim();
|
||||
if (!results[name]) results[name] = { pass: 0, fail: 0 };
|
||||
results[name].fail++;
|
||||
observed++;
|
||||
} else if (line.startsWith('ok ') && !line.includes('# skip') && !line.includes('# todo')) {
|
||||
const name = line.substring(line.indexOf('-') + 1).trim();
|
||||
if (!results[name]) results[name] = { pass: 0, fail: 0 };
|
||||
results[name].pass++;
|
||||
observed++;
|
||||
}
|
||||
}
|
||||
runDiagnostics.push({
|
||||
run: i + 1,
|
||||
code: code ?? 1,
|
||||
tapOutputPath,
|
||||
observed
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Ensure output directory exists before creating per-run TAP files
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
await runTests(i);
|
||||
}
|
||||
|
||||
const outputPath = join(outputDir, 'flakiness-matrix.json');
|
||||
writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
||||
console.log(`Flakiness matrix written to ${outputPath}.`);
|
||||
|
||||
const diagnosticsPath = join(outputDir, 'flakiness-runs.json');
|
||||
writeFileSync(diagnosticsPath, JSON.stringify(runDiagnostics, null, 2));
|
||||
|
||||
// Fail closed: an empty matrix means the signal is missing, not "no flakiness".
|
||||
const totalObserved = runDiagnostics.reduce((sum, run) => sum + run.observed, 0);
|
||||
if (totalObserved === 0) {
|
||||
console.error('No TAP test outcomes were observed across runs; flakiness report is not trustworthy.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,66 +0,0 @@
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
function getFiles(dir, files = []) {
|
||||
try {
|
||||
const fileList = readdirSync(dir);
|
||||
for (const file of fileList) {
|
||||
const name = join(dir, file);
|
||||
if (statSync(name).isDirectory()) {
|
||||
getFiles(name, files);
|
||||
} else if (name.endsWith('.mjs') || name.endsWith('.js')) {
|
||||
files.push(name);
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore if dir doesn't exist
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
let outputDir = 'reports/test-audit';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--output-dir') {
|
||||
outputDir = args[i + 1];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Only scan test/ directory now, as tests/ was consolidated
|
||||
const testFiles = [...getFiles('test')];
|
||||
const suspicious = [];
|
||||
|
||||
for (const file of testFiles) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
const issues = [];
|
||||
|
||||
if (content.includes('.only(')) issues.push('Found .only()');
|
||||
if (content.includes('.skip(')) issues.push('Found .skip()');
|
||||
if (content.includes('setTimeout(')) issues.push('Found setTimeout()');
|
||||
if (content.includes('sleep(')) issues.push('Found sleep()');
|
||||
|
||||
// Naive check for assertions: look for assert., expect(, t.
|
||||
// Many tests use 't' context from node:test
|
||||
if (!content.includes('assert.') && !content.includes('expect(') && !content.includes('t.plan') && !content.includes('strictEqual')) {
|
||||
// This is very naive, might false positive on 't.ok' etc.
|
||||
// Let's look for common assertion keywords.
|
||||
const assertionKeywords = ['assert', 'expect', 'strictEqual', 'deepStrictEqual', 'ok', 'equal'];
|
||||
const hasAssertion = assertionKeywords.some(k => content.includes(k));
|
||||
if (!hasAssertion) issues.push('No obvious assertions');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
suspicious.push({ file, issues });
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const outputPath = join(outputDir, 'suspicious-tests.json');
|
||||
writeFileSync(outputPath, JSON.stringify(suspicious, null, 2));
|
||||
console.log(`Found ${suspicious.length} suspicious files.`);
|
||||
console.log(`Report written to ${outputPath}.`);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const PROMPT_DIRS = ['src/prompts/daily', 'src/prompts/weekly'];
|
||||
|
||||
const REQUIRED_SECTION_HEADING = '## Required startup + artifacts + memory + issue capture';
|
||||
|
||||
const REQUIRED_TOKENS = [
|
||||
'`AGENTS.md`',
|
||||
'`CLAUDE.md`',
|
||||
'`KNOWN_ISSUES.md`',
|
||||
'`docs/agent-handoffs/README.md`',
|
||||
'`src/context/`',
|
||||
'`src/todo/`',
|
||||
'`src/decisions/`',
|
||||
'`src/test_logs/`',
|
||||
'`docs/agent-handoffs/incidents/`',
|
||||
'memory retrieval before implementation',
|
||||
'memory storage after implementation',
|
||||
];
|
||||
|
||||
async function listPromptFiles() {
|
||||
const files = [];
|
||||
|
||||
for (const dir of PROMPT_DIRS) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function validateFile(filePath, content) {
|
||||
const issues = [];
|
||||
|
||||
if (!content.includes(REQUIRED_SECTION_HEADING)) {
|
||||
issues.push(`missing required section heading: "${REQUIRED_SECTION_HEADING}"`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
const sectionStart = content.indexOf(REQUIRED_SECTION_HEADING);
|
||||
const remainder = content.slice(sectionStart + REQUIRED_SECTION_HEADING.length);
|
||||
const nextHeadingIndex = remainder.search(/\n##\s+/);
|
||||
const sectionBody =
|
||||
nextHeadingIndex >= 0 ? remainder.slice(0, nextHeadingIndex) : remainder;
|
||||
|
||||
for (const token of REQUIRED_TOKENS) {
|
||||
if (!sectionBody.includes(token)) {
|
||||
issues.push(`missing contract token in required section: ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
const files = await listPromptFiles();
|
||||
const errors = [];
|
||||
|
||||
for (const file of files) {
|
||||
const content = await readFile(file, 'utf8');
|
||||
const issues = validateFile(file, content);
|
||||
for (const issue of issues) {
|
||||
errors.push(`[prompt-contract] ${file}: ${issue}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Prompt contract drift detected:');
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Prompt contract validated for all daily/weekly prompts.');
|
||||
@@ -1,253 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const FILES = {
|
||||
meta: 'src/prompts/META_PROMPTS.md',
|
||||
flow: 'src/prompts/scheduler-flow.md',
|
||||
scheduler: 'scripts/agent/run-scheduler-cycle.mjs',
|
||||
schedulerConfig: 'scripts/agent/scheduler-config.mjs',
|
||||
schedulerLogger: 'scripts/agent/scheduler-logger.mjs',
|
||||
};
|
||||
|
||||
function collectMissing(text, requiredSnippets) {
|
||||
return requiredSnippets.filter((snippet) => !text.includes(snippet));
|
||||
}
|
||||
|
||||
function ensureOrdered(text, snippets) {
|
||||
const positions = [];
|
||||
for (const snippet of snippets) {
|
||||
const idx = text.indexOf(snippet);
|
||||
if (idx === -1) {
|
||||
return { ok: false, missing: snippet };
|
||||
}
|
||||
positions.push(idx);
|
||||
}
|
||||
|
||||
for (let i = 1; i < positions.length; i += 1) {
|
||||
if (positions[i] < positions[i - 1]) {
|
||||
return { ok: false, outOfOrder: [snippets[i - 1], snippets[i]] };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const invariants = [
|
||||
{
|
||||
name: 'lock check + exclusion fallback',
|
||||
promptSnippets: {
|
||||
meta: [
|
||||
'Run preflight to get the exclusion set:',
|
||||
'Use `excluded` from the JSON output as the canonical exclusion set.',
|
||||
'fallback to the union of `locked`, `paused`, and `completed`',
|
||||
],
|
||||
flow: [
|
||||
'canonical exclusion rule',
|
||||
],
|
||||
},
|
||||
// Logic remains in main scheduler script
|
||||
schedulerSnippets: [
|
||||
"runCommand('npm', ['run', `lock:check:${cadence}`",
|
||||
'checkPayload.excluded',
|
||||
'checkPayload.locked',
|
||||
'checkPayload.paused',
|
||||
'checkPayload.completed',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'roster selection source + all-excluded stop',
|
||||
promptSnippets: {
|
||||
meta: [
|
||||
'Read roster from src/prompts/roster.json',
|
||||
'scheduler.firstPromptByCadence.daily',
|
||||
'scheduler.firstPromptByCadence.weekly',
|
||||
'All roster tasks currently claimed by other agents',
|
||||
],
|
||||
flow: [
|
||||
'Roster source: `src/prompts/roster.json`',
|
||||
'Read `scheduler.firstPromptByCadence.<cadence>` from repository-root `torch-config.json` if present.',
|
||||
'All roster tasks currently claimed by other agents',
|
||||
],
|
||||
},
|
||||
// Logic split: roster loading in main, config loading in scheduler-config.mjs
|
||||
schedulerSnippets: [
|
||||
"readJson(path.resolve(process.cwd(), 'src/prompts/roster.json')",
|
||||
'parseFrontmatterAgent(content) || parseAgentFromFilename(latestFile)',
|
||||
'ALL_EXCLUDED_REASON',
|
||||
"reason: ALL_EXCLUDED_REASON",
|
||||
],
|
||||
// This snippet moved to scheduler-config.mjs
|
||||
configFileSnippets: [
|
||||
'firstPrompt: scheduler.firstPromptByCadence?.[cadence] || null',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'memory retrieve/store execution + required evidence enforcement',
|
||||
promptSnippets: {
|
||||
meta: [
|
||||
'Run required memory workflow for this cadence:',
|
||||
'Validate memory evidence:',
|
||||
'fail the run if either check is missing',
|
||||
],
|
||||
flow: [
|
||||
'Confirm memory contract completion:',
|
||||
'Memory retrieval evidence must exist for this run',
|
||||
'Memory storage evidence must exist for this run',
|
||||
'mode = required`, missing evidence is a hard failure',
|
||||
],
|
||||
},
|
||||
schedulerSnippets: [
|
||||
'schedulerConfig.memoryPolicy.retrieveCommand',
|
||||
'schedulerConfig.memoryPolicy.storeCommand',
|
||||
"name: 'retrieve'",
|
||||
"name: 'store'",
|
||||
"schedulerConfig.memoryPolicy.mode === 'required'",
|
||||
"reason: 'Required memory steps not verified'",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'artifact verification checkpoint',
|
||||
promptSnippets: {
|
||||
flow: [
|
||||
'Verify required run artifacts for the current run window.',
|
||||
'verify-run-artifacts.mjs --since <run-start-iso> --check-failure-notes',
|
||||
'If artifact verification exits non-zero: write `_failed.md` and stop.',
|
||||
],
|
||||
},
|
||||
schedulerSnippets: [
|
||||
"runCommand('node', [",
|
||||
"'scripts/agent/verify-run-artifacts.mjs'",
|
||||
"'--check-failure-notes'",
|
||||
"reason: 'Missing required run artifacts'",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'failure stop conditions + completion ordering',
|
||||
promptSnippets: {
|
||||
meta: [
|
||||
'If any validation command exits non-zero, do not call `lock:complete`',
|
||||
'Publish completion before writing `_completed.md`',
|
||||
'Only after step 11 succeeds, write final task log (`_completed.md`',
|
||||
],
|
||||
flow: [
|
||||
'step 12 MUST NOT be executed (`lock:complete` is forbidden until validation passes)',
|
||||
'Publish completion before writing final success log:',
|
||||
'`_completed.md` MUST be created only after completion publish succeeds.',
|
||||
'backend_category',
|
||||
'lock_command',
|
||||
'lock_stderr_excerpt',
|
||||
],
|
||||
},
|
||||
schedulerSnippets: [
|
||||
"reason: 'Validation failed'",
|
||||
'const completeResult = await runCommand(',
|
||||
"['run', 'lock:complete'",
|
||||
"status: 'completed'",
|
||||
'Completion publish failed. Retry npm run lock:complete',
|
||||
"reason: 'Lock backend error'",
|
||||
'classifyLockBackendError',
|
||||
'backend_category',
|
||||
'lock_command',
|
||||
'lock_stderr_excerpt',
|
||||
'lock_stdout_excerpt',
|
||||
],
|
||||
orderedSchedulerSnippets: [
|
||||
"reason: 'Validation failed'",
|
||||
'const completeResult = await runCommand(',
|
||||
"status: 'completed'",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'final summary print',
|
||||
promptSnippets: {
|
||||
meta: [
|
||||
'Print a final summary message to stdout',
|
||||
'Status: [Success/Failure]',
|
||||
'Learnings: [Content of the memory update file',
|
||||
],
|
||||
flow: [
|
||||
'Print a final summary message to stdout',
|
||||
'The message MUST include:',
|
||||
'**Learnings**: [Content of the memory update file',
|
||||
],
|
||||
},
|
||||
schedulerSnippets: [
|
||||
// This logic moved to scheduler-logger.mjs
|
||||
'exitWithSummary', // Still called in main script
|
||||
],
|
||||
loggerFileSnippets: [
|
||||
'async function printRunSummary',
|
||||
'async function exitWithSummary',
|
||||
'await printRunSummary(summaryData)',
|
||||
'Learnings / Discoveries:',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const [meta, flow, scheduler, schedulerConfig, schedulerLogger] = await Promise.all([
|
||||
readFile(FILES.meta, 'utf8'),
|
||||
readFile(FILES.flow, 'utf8'),
|
||||
readFile(FILES.scheduler, 'utf8'),
|
||||
readFile(FILES.schedulerConfig, 'utf8'),
|
||||
readFile(FILES.schedulerLogger, 'utf8'),
|
||||
]);
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const invariant of invariants) {
|
||||
if (invariant.promptSnippets?.meta) {
|
||||
const missingMeta = collectMissing(meta, invariant.promptSnippets.meta);
|
||||
for (const snippet of missingMeta) {
|
||||
errors.push(`[${invariant.name}] ${FILES.meta} missing snippet: ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (invariant.promptSnippets?.flow) {
|
||||
const missingFlow = collectMissing(flow, invariant.promptSnippets.flow);
|
||||
for (const snippet of missingFlow) {
|
||||
errors.push(`[${invariant.name}] ${FILES.flow} missing snippet: ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (invariant.schedulerSnippets) {
|
||||
const missingScheduler = collectMissing(scheduler, invariant.schedulerSnippets);
|
||||
for (const snippet of missingScheduler) {
|
||||
errors.push(`[${invariant.name}] ${FILES.scheduler} missing checkpoint snippet: ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (invariant.configFileSnippets) {
|
||||
const missingConfig = collectMissing(schedulerConfig, invariant.configFileSnippets);
|
||||
for (const snippet of missingConfig) {
|
||||
errors.push(`[${invariant.name}] ${FILES.schedulerConfig} missing checkpoint snippet: ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (invariant.loggerFileSnippets) {
|
||||
const missingLogger = collectMissing(schedulerLogger, invariant.loggerFileSnippets);
|
||||
for (const snippet of missingLogger) {
|
||||
errors.push(`[${invariant.name}] ${FILES.schedulerLogger} missing checkpoint snippet: ${snippet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (invariant.orderedSchedulerSnippets) {
|
||||
const order = ensureOrdered(scheduler, invariant.orderedSchedulerSnippets);
|
||||
if (!order.ok && order.missing) {
|
||||
errors.push(`[${invariant.name}] ${FILES.scheduler} missing ordered snippet: ${order.missing}`);
|
||||
} else if (!order.ok && order.outOfOrder) {
|
||||
errors.push(
|
||||
`[${invariant.name}] ${FILES.scheduler} ordering drift: "${order.outOfOrder[0]}" must appear before "${order.outOfOrder[1]}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Scheduler flow parity drift detected:');
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Scheduler flow parity validated for prompts and scheduler implementation.');
|
||||
@@ -1,163 +0,0 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const CONFIG = {
|
||||
daily: {
|
||||
schedulerFile: 'src/prompts/daily-scheduler.md',
|
||||
promptDir: 'src/prompts/daily',
|
||||
},
|
||||
weekly: {
|
||||
schedulerFile: 'src/prompts/weekly-scheduler.md',
|
||||
promptDir: 'src/prompts/weekly',
|
||||
},
|
||||
};
|
||||
|
||||
function parseTableRows(markdown, schedulerFile) {
|
||||
const rows = [];
|
||||
for (const line of markdown.split('\n')) {
|
||||
const match = line.match(/^\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*`([^`]+)`\s*\|\s*$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
index: Number(match[1]),
|
||||
agent: match[2].trim(),
|
||||
promptFile: match[3].trim(),
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`No scheduler table rows found in ${schedulerFile}`);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function diffLists(expected, actual) {
|
||||
return {
|
||||
missing: expected.filter((item) => !actual.includes(item)),
|
||||
extra: actual.filter((item) => !expected.includes(item)),
|
||||
};
|
||||
}
|
||||
|
||||
function validateScheduler(cadence, roster, rows, promptFiles) {
|
||||
const errors = [];
|
||||
|
||||
if (rows.length !== roster.length) {
|
||||
errors.push(
|
||||
`[${cadence}] row count mismatch: roster has ${roster.length}, table has ${rows.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
const row = rows[i];
|
||||
const expectedIndex = i + 1;
|
||||
if (row.index !== expectedIndex) {
|
||||
errors.push(`[${cadence}] row ${i + 1} has index ${row.index}, expected ${expectedIndex}`);
|
||||
}
|
||||
|
||||
const expectedAgent = roster[i];
|
||||
if (expectedAgent && row.agent !== expectedAgent) {
|
||||
errors.push(
|
||||
`[${cadence}] row ${i + 1} agent mismatch: table has "${row.agent}", roster expects "${expectedAgent}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const expectedPromptFile = `${row.agent}.md`;
|
||||
if (row.promptFile !== expectedPromptFile) {
|
||||
errors.push(
|
||||
`[${cadence}] row ${i + 1} prompt file mismatch: table has "${row.promptFile}", expected "${expectedPromptFile}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tableAgents = rows.map((row) => row.agent);
|
||||
const rosterVsTable = diffLists(roster, tableAgents);
|
||||
if (rosterVsTable.missing.length > 0 || rosterVsTable.extra.length > 0) {
|
||||
errors.push(
|
||||
`[${cadence}] roster/table mismatch: missing in table [${rosterVsTable.missing.join(', ')}], extra in table [${rosterVsTable.extra.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
const rosterPromptFiles = roster.map((agent) => `${agent}.md`);
|
||||
const filesVsRoster = diffLists(rosterPromptFiles, promptFiles);
|
||||
if (filesVsRoster.missing.length > 0 || filesVsRoster.extra.length > 0) {
|
||||
errors.push(
|
||||
`[${cadence}] roster/files mismatch: missing files [${filesVsRoster.missing.join(', ')}], extra files [${filesVsRoster.extra.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
const filesVsTable = diffLists(promptFiles, rows.map((row) => row.promptFile));
|
||||
if (filesVsTable.missing.length > 0 || filesVsTable.extra.length > 0) {
|
||||
errors.push(
|
||||
`[${cadence}] table/files mismatch: missing in table [${filesVsTable.missing.join(', ')}], extra in table [${filesVsTable.extra.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function listMarkdownFiles(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listMarkdownFiles(fullPath)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function validateCanonicalPromptPaths() {
|
||||
const errors = [];
|
||||
const markdownFiles = await listMarkdownFiles('src/prompts');
|
||||
const invalidPathPattern = /src\/src\/(context|todo|decisions|test_logs)\b/;
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const content = await readFile(file, 'utf8');
|
||||
if (!invalidPathPattern.test(content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
errors.push(
|
||||
`[prompt-paths] ${file} contains invalid canonical path references using "src/src/..."; expected "src/..."`,
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
const roster = JSON.parse(await readFile('src/prompts/roster.json', 'utf8'));
|
||||
|
||||
const allErrors = [];
|
||||
|
||||
for (const [cadence, cfg] of Object.entries(CONFIG)) {
|
||||
const schedulerMd = await readFile(cfg.schedulerFile, 'utf8');
|
||||
const rows = parseTableRows(schedulerMd, cfg.schedulerFile);
|
||||
const promptFiles = (await readdir(cfg.promptDir))
|
||||
.filter((filename) => filename.endsWith('.md'))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
allErrors.push(...validateScheduler(cadence, roster[cadence] ?? [], rows, promptFiles));
|
||||
}
|
||||
|
||||
allErrors.push(...(await validateCanonicalPromptPaths()));
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
console.error('Scheduler/roster drift detected:');
|
||||
for (const error of allErrors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Scheduler tables, roster, and prompt filenames are in sync.');
|
||||
Reference in new issue
Block a user