mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 10:52:05 +00:00
Merge branch 'beta' into main (safe conflict resolution)
This commit is contained in:
commit
37d0dffdc3
462 files changed
+68862
-6529
No files matched your search
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
})();
|
||||
+167
-138
@@ -1,172 +1,201 @@
|
||||
import { Relay } from 'nostr-tools/relay';
|
||||
import WebSocket from 'ws';
|
||||
import { useWebSocketImplementation } from 'nostr-tools/relay';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import { generateSecretKey, finalizeEvent } from 'nostr-tools/pure';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { ensureDir } from '../../src/utils.mjs';
|
||||
|
||||
// --- Configuration ---
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const RELAY_URL = process.env.RELAY_URL;
|
||||
const CLIENTS = parseInt(process.env.CLIENTS || '1000', 10);
|
||||
const DURATION_SEC = parseInt(process.env.DURATION_SEC || '600', 10);
|
||||
const RATE_EPS = parseInt(process.env.RATE_EPS || '10', 10);
|
||||
const MIX = parseFloat(process.env.MIX || '0.9'); // 90% view events, 10% metadata
|
||||
const DRY_RUN = process.env.DRY_RUN === '1';
|
||||
const REPORT_DIR = 'artifacts';
|
||||
const CLIENTS = parseInt(process.env.CLIENTS || '10', 10);
|
||||
const DURATION_SEC = parseInt(process.env.DURATION_SEC || '10', 10);
|
||||
const RATE_EPS = parseInt(process.env.RATE_EPS || '5', 10);
|
||||
const MIX = parseFloat(process.env.MIX || '0.1'); // 10% metadata
|
||||
const DRY_RUN = process.env.DRY_RUN !== '0'; // Default to true for safety
|
||||
|
||||
// --- Helpers ---
|
||||
const KIND_TEXT = 1;
|
||||
const KIND_METADATA = 30078; // App Data as proxy for metadata
|
||||
|
||||
// const nowUnix = () => Math.floor(Date.now() / 1000);
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
console.log('Load Test Configuration:');
|
||||
console.log(` RELAY_URL: ${RELAY_URL || '(none)'}`);
|
||||
console.log(` CLIENTS: ${CLIENTS}`);
|
||||
console.log(` DURATION_SEC: ${DURATION_SEC}`);
|
||||
console.log(` RATE_EPS: ${RATE_EPS}`);
|
||||
console.log(` MIX: ${MIX}`);
|
||||
console.log(` DRY_RUN: ${DRY_RUN}`);
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!RELAY_URL && !DRY_RUN) {
|
||||
console.error('ERROR: RELAY_URL is required unless DRY_RUN=1');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Logic ---
|
||||
const stats = {
|
||||
totalAttempts: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
latencySum: 0,
|
||||
latencyMin: Infinity,
|
||||
latencyMax: 0,
|
||||
errors: {},
|
||||
startTime: Date.now(),
|
||||
endTime: 0
|
||||
};
|
||||
|
||||
async function runLoadTest() {
|
||||
console.log(`Starting Load Test Agent...`);
|
||||
console.log(`Config: CLIENTS=${CLIENTS}, DURATION=${DURATION_SEC}s, RATE=${RATE_EPS}eps, MIX=${MIX}, DRY_RUN=${DRY_RUN}`);
|
||||
async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
if (!RELAY_URL && !DRY_RUN) {
|
||||
console.error('ERROR: RELAY_URL environment variable is required (unless DRY_RUN=1).');
|
||||
process.exit(1);
|
||||
function generateEvent(sk) {
|
||||
const isMetadata = Math.random() < MIX;
|
||||
const kind = isMetadata ? KIND_METADATA : KIND_TEXT;
|
||||
const content = isMetadata
|
||||
? JSON.stringify({ type: 'video-metadata', title: 'Load Test Video', duration: 120, tags: ['test', 'load'] })
|
||||
: `Load test view event ${Date.now()}`;
|
||||
|
||||
const tags = [['t', 'load-test']];
|
||||
if (isMetadata) {
|
||||
tags.push(['d', `load-test-${Date.now()}-${Math.random()}`]);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const endTime = startTime + (DURATION_SEC * 1000);
|
||||
return finalizeEvent({
|
||||
kind,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content,
|
||||
}, sk);
|
||||
}
|
||||
|
||||
let totalEventsAttempted = 0;
|
||||
let totalEventsSuccess = 0;
|
||||
let totalEventsFailed = 0;
|
||||
let latencies = [];
|
||||
let errors = {};
|
||||
async function main() {
|
||||
const reportDir = 'reports/load-test';
|
||||
ensureDir(reportDir);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('DRY RUN MODE: Simulating load without network calls.');
|
||||
|
||||
while (Date.now() < endTime) {
|
||||
// Simulate rate
|
||||
const batchSize = Math.ceil(RATE_EPS / 10); // Check 10 times per second
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
totalEventsAttempted++;
|
||||
// Simulate random success/failure and latency
|
||||
const isSuccess = Math.random() > 0.01; // 1% failure
|
||||
if (isSuccess) {
|
||||
totalEventsSuccess++;
|
||||
latencies.push(Math.floor(Math.random() * 50) + 10); // 10-60ms
|
||||
} else {
|
||||
totalEventsFailed++;
|
||||
const errType = Math.random() > 0.5 ? 'Timeout' : 'ConnectionRefused';
|
||||
errors[errType] = (errors[errType] || 0) + 1;
|
||||
}
|
||||
}
|
||||
await sleep(100);
|
||||
console.log('Starting DRY RUN...');
|
||||
const start = Date.now();
|
||||
let eventsGenerated = 0;
|
||||
// Simple simulation loop
|
||||
while (Date.now() - start < DURATION_SEC * 1000) {
|
||||
eventsGenerated++;
|
||||
if (eventsGenerated % RATE_EPS === 0) await sleep(1000);
|
||||
}
|
||||
stats.totalAttempts = eventsGenerated;
|
||||
stats.success = eventsGenerated;
|
||||
stats.endTime = Date.now();
|
||||
stats.latencySum = eventsGenerated * 10; // Fake 10ms latency
|
||||
stats.latencyMin = 5;
|
||||
stats.latencyMax = 15;
|
||||
|
||||
} else {
|
||||
console.log(`Connecting to ${RELAY_URL} with ${CLIENTS} simulated clients (implementation pending, using simplified single-connection for initial harness)...`);
|
||||
|
||||
// For this initial version, we will use a single connection to validate the harness structure
|
||||
// as properly simulating 1000 clients requires more complex setup (workers/etc) which is out of scope for "Minimal footprint".
|
||||
// We will simulate the *rate* but maybe not full connection concurrency in this first pass if strictly constrained.
|
||||
// However, the prompt asks for "Create N clients".
|
||||
// Let's try to create a few clients to prove we can.
|
||||
|
||||
const activeRelays = [];
|
||||
let relay;
|
||||
try {
|
||||
relay = await Relay.connect(RELAY_URL);
|
||||
activeRelays.push(relay);
|
||||
console.log(`Connected to ${RELAY_URL}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${RELAY_URL}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate ephemeral key for signing
|
||||
const sk = generateSecretKey();
|
||||
// const pk = getPublicKey(sk); // Unused for now
|
||||
|
||||
// Load Loop
|
||||
while (Date.now() < endTime) {
|
||||
const batchStart = Date.now();
|
||||
const batchSize = Math.ceil(RATE_EPS / 10);
|
||||
|
||||
const promises = [];
|
||||
for (let i=0; i<batchSize; i++) {
|
||||
totalEventsAttempted++;
|
||||
|
||||
const eventTemplate = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [],
|
||||
content: `Load test event ${totalEventsAttempted}`,
|
||||
};
|
||||
|
||||
const event = finalizeEvent(eventTemplate, sk);
|
||||
|
||||
promises.push((async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await relay.publish(event);
|
||||
const latency = Date.now() - start;
|
||||
totalEventsSuccess++;
|
||||
latencies.push(latency);
|
||||
} catch (err) {
|
||||
totalEventsFailed++;
|
||||
const msg = err.message || String(err);
|
||||
errors[msg] = (errors[msg] || 0) + 1;
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const elapsed = Date.now() - batchStart;
|
||||
if (elapsed < 100) {
|
||||
await sleep(100 - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
activeRelays.forEach(r => r.close());
|
||||
writeReport(reportDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Report
|
||||
latencies.sort((a, b) => a - b);
|
||||
const p50 = latencies[Math.floor(latencies.length * 0.5)] || 0;
|
||||
const p90 = latencies[Math.floor(latencies.length * 0.9)] || 0;
|
||||
const p95 = latencies[Math.floor(latencies.length * 0.95)] || 0;
|
||||
const p99 = latencies[Math.floor(latencies.length * 0.99)] || 0;
|
||||
const pool = new SimplePool();
|
||||
const sks = Array.from({ length: CLIENTS }, () => generateSecretKey());
|
||||
|
||||
console.log(`Starting load test against ${RELAY_URL} for ${DURATION_SEC}s...`);
|
||||
|
||||
const start = Date.now();
|
||||
const end = start + DURATION_SEC * 1000;
|
||||
|
||||
const interval = 1000 / RATE_EPS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() >= end) {
|
||||
clearInterval(timer);
|
||||
pool.close([RELAY_URL]);
|
||||
stats.endTime = Date.now();
|
||||
writeReport(reportDir);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIdx = Math.floor(Math.random() * CLIENTS);
|
||||
const sk = sks[clientIdx];
|
||||
const event = generateEvent(sk);
|
||||
|
||||
stats.totalAttempts++;
|
||||
const sendStart = Date.now();
|
||||
|
||||
try {
|
||||
await Promise.any(pool.publish([RELAY_URL], event));
|
||||
const latency = Date.now() - sendStart;
|
||||
stats.success++;
|
||||
stats.latencySum += latency;
|
||||
stats.latencyMin = Math.min(stats.latencyMin, latency);
|
||||
stats.latencyMax = Math.max(stats.latencyMax, latency);
|
||||
} catch (err) {
|
||||
stats.failed++;
|
||||
const msg = err.message || String(err);
|
||||
stats.errors[msg] = (stats.errors[msg] || 0) + 1;
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
function writeReport(dir) {
|
||||
const duration = (stats.endTime - stats.startTime) / 1000;
|
||||
const throughput = stats.success / duration;
|
||||
const avgLatency = stats.success > 0 ? stats.latencySum / stats.success : 0;
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
config: {
|
||||
relayUrl: DRY_RUN ? 'N/A (Dry Run)' : RELAY_URL,
|
||||
relayUrl: RELAY_URL,
|
||||
clients: CLIENTS,
|
||||
durationSec: DURATION_SEC,
|
||||
rateEps: RATE_EPS,
|
||||
mix: MIX
|
||||
mix: MIX,
|
||||
dryRun: DRY_RUN
|
||||
},
|
||||
summary: {
|
||||
totalEventsAttempted,
|
||||
totalEventsSuccess,
|
||||
totalEventsFailed,
|
||||
throughputEps: totalEventsSuccess / DURATION_SEC,
|
||||
latencies: { p50, p90, p95, p99 }
|
||||
results: {
|
||||
totalAttempts: stats.totalAttempts,
|
||||
success: stats.success,
|
||||
failed: stats.failed,
|
||||
throughputEps: throughput,
|
||||
latency: {
|
||||
min: stats.latencyMin === Infinity ? 0 : stats.latencyMin,
|
||||
max: stats.latencyMax,
|
||||
avg: avgLatency
|
||||
},
|
||||
errors: stats.errors
|
||||
},
|
||||
errors
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
ensureDir(REPORT_DIR);
|
||||
const reportPath = path.join(REPORT_DIR, `load-report-${new Date().toISOString().split('T')[0]}.json`);
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`Report generated at ${reportPath}`);
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
const jsonPath = path.join(dir, `load-report-${dateStr}.json`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2));
|
||||
console.log(`JSON report written to ${jsonPath}`);
|
||||
|
||||
const mdPath = path.join(dir, `load-test-report-${dateStr}.md`);
|
||||
const mdContent = `
|
||||
# Load Test Report - ${dateStr}
|
||||
|
||||
## Configuration
|
||||
- **Relay**: ${RELAY_URL || '(dry run)'}
|
||||
- **Clients**: ${CLIENTS}
|
||||
- **Duration**: ${DURATION_SEC}s
|
||||
- **Rate**: ${RATE_EPS} eps
|
||||
- **Mix**: ${MIX}
|
||||
- **Dry Run**: ${DRY_RUN}
|
||||
|
||||
## Results
|
||||
- **Total Attempts**: ${stats.totalAttempts}
|
||||
- **Success**: ${stats.success}
|
||||
- **Failed**: ${stats.failed}
|
||||
- **Throughput**: ${throughput.toFixed(2)} events/sec
|
||||
- **Latency (Avg)**: ${avgLatency.toFixed(2)} ms
|
||||
- **Latency (Min/Max)**: ${stats.latencyMin === Infinity ? 0 : stats.latencyMin} / ${stats.latencyMax} ms
|
||||
|
||||
## Errors
|
||||
\`\`\`json
|
||||
${JSON.stringify(stats.errors, null, 2)}
|
||||
\`\`\`
|
||||
`;
|
||||
fs.writeFileSync(mdPath, mdContent.trim());
|
||||
console.log(`Markdown report written to ${mdPath}`);
|
||||
}
|
||||
|
||||
runLoadTest().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
main().catch(console.error);
|
||||
@@ -3,9 +3,30 @@
|
||||
// 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 { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
runCommand,
|
||||
parseJsonFromOutput,
|
||||
readJson,
|
||||
normalizeStringList,
|
||||
parseNonNegativeInt,
|
||||
parseBooleanFlag,
|
||||
excerptText,
|
||||
getRunDateKey,
|
||||
toYamlScalar,
|
||||
} from './scheduler-utils.mjs';
|
||||
|
||||
import {
|
||||
classifyLockBackendError,
|
||||
buildLockBackendRemediation,
|
||||
runLockHealthPreflight,
|
||||
acquireLockWithRetry,
|
||||
summarizeLockFailureReasons,
|
||||
} from './scheduler-lock.mjs';
|
||||
|
||||
import { detectPlatform } from '../../src/utils.mjs';
|
||||
|
||||
const VALID_CADENCES = new Set(['daily', 'weekly']);
|
||||
const ALL_EXCLUDED_REASON = 'All roster tasks currently claimed by other agents';
|
||||
const FAILURE_CATEGORY = {
|
||||
@@ -15,10 +36,8 @@ const FAILURE_CATEGORY = {
|
||||
EXECUTION: 'execution_error',
|
||||
};
|
||||
|
||||
const LOCK_INCIDENT_LINK = 'docs/agent-handoffs/learnings/2026-02-15-relay-health-preflight-job.md';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { cadence: null, platform: process.env.AGENT_PLATFORM || 'codex', model: process.env.AGENT_MODEL || null };
|
||||
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) {
|
||||
@@ -46,145 +65,6 @@ function ts() {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function runCommand(command, args = [], options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, ...(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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readJson(filePath, fallback) {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStringList(value, fallback = []) {
|
||||
if (!Array.isArray(value)) return fallback;
|
||||
return value.filter((item) => typeof item === 'string' && item.trim()).map((item) => item.trim());
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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]');
|
||||
}
|
||||
|
||||
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)}…`;
|
||||
}
|
||||
|
||||
function categorizeFailureMetadata(failureCategory, extraMetadata = {}) {
|
||||
return {
|
||||
failure_category: failureCategory,
|
||||
@@ -192,26 +72,6 @@ function categorizeFailureMetadata(failureCategory, extraMetadata = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function formatDurationMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
||||
const minutes = Math.round(ms / 60000);
|
||||
return `${minutes} minute(s)`;
|
||||
}
|
||||
|
||||
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(' ')}`;
|
||||
}
|
||||
|
||||
async function validatePromptFile(promptPath) {
|
||||
let content;
|
||||
try {
|
||||
@@ -227,59 +87,18 @@ async function validatePromptFile(promptPath) {
|
||||
|
||||
const lines = String(content).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const firstLine = lines[0] || '';
|
||||
if (!firstLine.startsWith('#')) {
|
||||
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 on first non-empty line in ${promptPath}.`,
|
||||
detail: `Prompt not executed; expected markdown heading or blockquote on first non-empty line in ${promptPath}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
async function artifactExistsSince(filePath, sinceMs) {
|
||||
if (!filePath) return false;
|
||||
const resolved = path.isAbsolute(filePath)
|
||||
@@ -295,14 +114,8 @@ async function artifactExistsSince(filePath, sinceMs) {
|
||||
|
||||
async function verifyMemoryStep({ name, markers, artifacts, outputText, sinceMs }) {
|
||||
const markerMatched = markers.some((marker) => outputText.includes(marker));
|
||||
let artifactMatched = false;
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
if (await artifactExistsSince(artifact, sinceMs)) {
|
||||
artifactMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const artifactResults = await Promise.all(artifacts.map((artifact) => artifactExistsSince(artifact, sinceMs)));
|
||||
const artifactMatched = artifactResults.some(Boolean);
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -371,22 +184,28 @@ async function getLatestFile(logDir) {
|
||||
.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 filename of files) {
|
||||
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.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latest || effectiveMs > latest.effectiveMs) {
|
||||
latest = { filename, effectiveMs };
|
||||
for (const result of results) {
|
||||
if (!result) continue;
|
||||
if (!latest || result.effectiveMs > latest.effectiveMs) {
|
||||
latest = result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +273,7 @@ async function getSchedulerConfig(cadence, { isInteractive }) {
|
||||
missingHandoffCommandForMode,
|
||||
validationCommands: Array.isArray(scheduler.validationCommandsByCadence?.[cadence])
|
||||
? scheduler.validationCommandsByCadence[cadence].filter((cmd) => typeof cmd === 'string' && cmd.trim())
|
||||
: ['npm', 'run', 'lint'],
|
||||
: ['npm run lint'],
|
||||
lockRetry: {
|
||||
maxRetries,
|
||||
backoffMs,
|
||||
@@ -485,10 +304,6 @@ async function getSchedulerConfig(cadence, { isInteractive }) {
|
||||
};
|
||||
}
|
||||
|
||||
function getRunDateKey(now = new Date()) {
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -519,122 +334,6 @@ function createIdempotencyKey({ cadence, selectedAgent, runDate }) {
|
||||
return `${cadence}:${selectedAgent}:${runDate}:${randomUUID()}`;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeLockFailureReasons(outputText) {
|
||||
const events = parseJsonEventsFromOutput(outputText);
|
||||
const quorumFailure = [...events].reverse().find((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,
|
||||
};
|
||||
}
|
||||
|
||||
function toYamlScalar(value) {
|
||||
const str = String(value ?? '');
|
||||
return `'${str.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
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 });
|
||||
@@ -900,9 +599,21 @@ async function main() {
|
||||
const runStartMs = Date.parse(runArtifactSince);
|
||||
const outputChunks = [];
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
if (schedulerConfig.memoryPolicy.retrieveCommand) {
|
||||
const retrieveResult = await runCommand('bash', ['-lc', schedulerConfig.memoryPolicy.retrieveCommand], {
|
||||
env: { AGENT_PLATFORM: platform, ...(model ? { AGENT_MODEL: model } : {}), SCHEDULER_AGENT: selectedAgent, SCHEDULER_CADENCE: cadence, SCHEDULER_PROMPT_PATH: promptPath },
|
||||
env: schedulerEnv,
|
||||
});
|
||||
outputChunks.push(retrieveResult.stdout, retrieveResult.stderr);
|
||||
if (retrieveResult.code !== 0) {
|
||||
@@ -921,7 +632,7 @@ async function main() {
|
||||
|
||||
if (schedulerConfig.handoffCommand) {
|
||||
const handoff = await runCommand('bash', ['-lc', schedulerConfig.handoffCommand], {
|
||||
env: { AGENT_PLATFORM: platform, ...(model ? { AGENT_MODEL: model } : {}), SCHEDULER_AGENT: selectedAgent, SCHEDULER_CADENCE: cadence, SCHEDULER_PROMPT_PATH: promptPath },
|
||||
env: schedulerEnv,
|
||||
});
|
||||
outputChunks.push(handoff.stdout, handoff.stderr);
|
||||
if (handoff.code !== 0) {
|
||||
@@ -954,7 +665,7 @@ async function main() {
|
||||
|
||||
if (schedulerConfig.memoryPolicy.storeCommand) {
|
||||
const storeResult = await runCommand('bash', ['-lc', schedulerConfig.memoryPolicy.storeCommand], {
|
||||
env: { AGENT_PLATFORM: platform, ...(model ? { AGENT_MODEL: model } : {}), SCHEDULER_AGENT: selectedAgent, SCHEDULER_CADENCE: cadence, SCHEDULER_PROMPT_PATH: promptPath },
|
||||
env: schedulerEnv,
|
||||
});
|
||||
outputChunks.push(storeResult.stdout, storeResult.stderr);
|
||||
if (storeResult.code !== 0) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import fs from 'node:fs/promises';
|
||||
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));
|
||||
}
|
||||
@@ -182,10 +182,16 @@ async function main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const promises = entries.map(async (entry) => {
|
||||
const fullPath = path.join(cadenceDir, entry);
|
||||
logsScanned += 1;
|
||||
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);
|
||||
|
||||
@@ -158,7 +158,8 @@ function extractIdentifiers(content) {
|
||||
const ids = new Set();
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (/(issue|incident|failure)[-_\s]*id/i.test(line)) {
|
||||
// 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]);
|
||||
}
|
||||
@@ -242,8 +243,19 @@ async function checkFailureTracking({ sinceMs }) {
|
||||
|
||||
for (const file of recentTestLogs) {
|
||||
const content = await fs.readFile(file.filePath, 'utf8').catch(() => '');
|
||||
if (FAILURE_KEYWORD_RE.test(content)) {
|
||||
const ids = extractIdentifiers(content);
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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);
|
||||
@@ -0,0 +1,111 @@
|
||||
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()
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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');
|
||||
writeFileSync(path.join(latestDir, 'memories.md'), retrieved.map(m => `- ${m.content}`).join('\n'));
|
||||
|
||||
// 5. Output success marker to stdout (for scheduler verification)
|
||||
console.log('MEMORY_RETRIEVED');
|
||||
} catch (error) {
|
||||
console.error('Memory retrieval failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 = 'weekly';
|
||||
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 defaultFile = 'memory-update.md';
|
||||
|
||||
const targetFile = explicitFile || (envFile && existsSync(envFile) ? envFile : (existsSync(defaultFile) ? defaultFile : 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 {
|
||||
// Fallback if no file provided (preserve existing behavior for compatibility)
|
||||
console.warn(`No memory input found (checked --file, env SCHEDULER_MEMORY_FILE=${envFile}, or default ${defaultFile}). using fallback placeholder.`);
|
||||
events = [
|
||||
{
|
||||
agent_id: agentId,
|
||||
content: `Store memory event A for ${cadence} :: ${promptIntent}`,
|
||||
timestamp: baseTs,
|
||||
tags: ['scheduler', cadence, 'store'],
|
||||
metadata: {
|
||||
session_id: runId,
|
||||
source: 'scheduler-store',
|
||||
importance: 0.55,
|
||||
prompt_path: promptPath
|
||||
}
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
content: `Store memory event B for ${cadence} :: ${promptIntent}`,
|
||||
timestamp: baseTs + 1,
|
||||
tags: ['scheduler', cadence, 'store'],
|
||||
metadata: {
|
||||
session_id: runId,
|
||||
source: 'scheduler-store',
|
||||
importance: 0.55,
|
||||
prompt_path: promptPath
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
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 || 'fallback'
|
||||
},
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const RUNS = 5;
|
||||
const results = {};
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests(i) {
|
||||
console.log(`Run ${i + 1}/${RUNS}...`);
|
||||
return new Promise((resolve) => {
|
||||
// Pass strictly the test args to the child process
|
||||
const child = spawn('node', ['--test', ...testArgs], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
|
||||
child.stdout.on('data', (d) => stdout += d.toString());
|
||||
|
||||
child.on('close', (code) => {
|
||||
const lines = stdout.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++;
|
||||
} 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++;
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
await runTests(i);
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const outputPath = join(outputDir, 'flakiness-matrix.json');
|
||||
writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
||||
console.log(`Flakiness matrix written to ${outputPath}.`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,66 @@
|
||||
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}.`);
|
||||
Reference in new issue
Block a user