mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-04 03:41:54 +00:00
Merge branch 'main' into test/upload-validation-15691315589845316357
This commit is contained in:
33 files changed
+9337
-1079
No files matched your search
+10
-7
@@ -2,8 +2,6 @@ import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const BACKUP_ROOT = path.resolve(process.cwd(), '.torch/backups');
|
||||
|
||||
// Paths to snapshot on each backup run
|
||||
const STATE_SOURCES = [
|
||||
'.scheduler-memory/memory-store.json',
|
||||
@@ -22,19 +20,21 @@ const STATE_SOURCES = [
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string|null} [opts.output] - Override destination directory (default: .torch/backups/<ts>)
|
||||
* @param {string} [opts.cwd] - Working directory (default: process.cwd())
|
||||
* @returns {Promise<{backupDir: string, manifest: Object}>}
|
||||
*/
|
||||
export async function cmdBackup({ output = null } = {}) {
|
||||
export async function cmdBackup({ output = null, cwd = process.cwd() } = {}) {
|
||||
const BACKUP_ROOT = path.resolve(cwd, '.torch/backups');
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupDir = output
|
||||
? path.resolve(process.cwd(), output)
|
||||
? path.resolve(cwd, output)
|
||||
: path.join(BACKUP_ROOT, ts);
|
||||
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
let gitCommit = null;
|
||||
try {
|
||||
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
|
||||
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe', cwd }).toString().trim();
|
||||
} catch {
|
||||
// Not in a git repo or git unavailable
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export async function cmdBackup({ output = null } = {}) {
|
||||
const skipped = [];
|
||||
|
||||
for (const relPath of STATE_SOURCES) {
|
||||
const src = path.resolve(process.cwd(), relPath);
|
||||
const src = path.resolve(cwd, relPath);
|
||||
const dest = path.join(backupDir, relPath.replace(/\//g, '__'));
|
||||
try {
|
||||
await fs.copyFile(src, dest);
|
||||
@@ -77,9 +77,12 @@ export async function cmdBackup({ output = null } = {}) {
|
||||
/**
|
||||
* Lists all available backups under .torch/backups/, newest first.
|
||||
*
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.cwd] - Working directory (default: process.cwd())
|
||||
* @returns {Promise<Array<{id: string, createdAt: string|null, backupDir: string}>>}
|
||||
*/
|
||||
export async function listBackups() {
|
||||
export async function listBackups({ cwd = process.cwd() } = {}) {
|
||||
const BACKUP_ROOT = path.resolve(cwd, '.torch/backups');
|
||||
try {
|
||||
const entries = await fs.readdir(BACKUP_ROOT, { withFileTypes: true });
|
||||
const backups = [];
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure';
|
||||
import {
|
||||
getRelays as _getRelays,
|
||||
getNamespace as _getNamespace,
|
||||
getHashtag as _getHashtag,
|
||||
getTtl as _getTtl,
|
||||
} from './torch-config.mjs';
|
||||
import {
|
||||
KIND_APP_DATA,
|
||||
RACE_CHECK_DELAY_MS,
|
||||
MS_PER_SECOND,
|
||||
} from './constants.mjs';
|
||||
import { getRoster as _getRoster } from './roster.mjs';
|
||||
import { queryLocks as _queryLocks, publishLock as _publishLock } from './lock-ops.mjs';
|
||||
import { ExitError } from './errors.mjs';
|
||||
import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
function getGitCommit() {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPromptInfo(agent, cadence) {
|
||||
try {
|
||||
const potentialPath = path.join(process.cwd(), 'src', 'prompts', cadence, `${agent}.md`);
|
||||
const content = await fs.readFile(potentialPath, 'utf8');
|
||||
const promptHash = createHash('sha256').update(content).digest('hex');
|
||||
const promptPath = `src/prompts/${cadence}/${agent}.md`;
|
||||
return { promptHash, promptPath };
|
||||
} catch {
|
||||
return { promptHash: null, promptPath: null };
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoster(agent, roster, cadence, error) {
|
||||
if (!roster.includes(agent)) {
|
||||
error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
|
||||
error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
|
||||
throw new ExitError(1, 'Agent not in roster');
|
||||
}
|
||||
}
|
||||
|
||||
function checkExistingLocks(existingLocks, agent, error, log) {
|
||||
const conflicting = existingLocks.filter((l) => l.agent === agent);
|
||||
|
||||
if (conflicting.length > 0) {
|
||||
const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
|
||||
if (earliest.status === 'completed') {
|
||||
error(`LOCK DENIED: Task already completed by event ${earliest.eventId}`);
|
||||
log('LOCK_STATUS=denied');
|
||||
log('LOCK_REASON=already_completed');
|
||||
throw new ExitError(3, 'Task already completed');
|
||||
}
|
||||
|
||||
error(
|
||||
`LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
|
||||
`(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
|
||||
);
|
||||
log('LOCK_STATUS=denied');
|
||||
log('LOCK_REASON=already_locked');
|
||||
log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
|
||||
throw new ExitError(3, 'Lock denied');
|
||||
}
|
||||
}
|
||||
|
||||
function buildLockEvent(agent, cadence, namespace, hashtag, dateStr, now, expiresAt, platform, model, gitCommit, promptPath, promptHash, finalizeEvent, sk) {
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: KIND_APP_DATA,
|
||||
created_at: now,
|
||||
tags: [
|
||||
['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
|
||||
['t', hashtag],
|
||||
['t', `${namespace}-lock-${cadence}`],
|
||||
['t', `${namespace}-lock-${cadence}-${dateStr}`],
|
||||
['expiration', String(expiresAt)],
|
||||
],
|
||||
content: JSON.stringify({
|
||||
agent,
|
||||
cadence,
|
||||
status: 'started',
|
||||
namespace,
|
||||
date: dateStr,
|
||||
platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown',
|
||||
model: model || process.env.AGENT_MODEL || 'unknown',
|
||||
lockedAt: new Date(now * MS_PER_SECOND).toISOString(),
|
||||
expiresAt: new Date(expiresAt * MS_PER_SECOND).toISOString(),
|
||||
gitCommit,
|
||||
promptPath,
|
||||
promptHash,
|
||||
}),
|
||||
},
|
||||
sk,
|
||||
);
|
||||
}
|
||||
|
||||
async function performRaceCheck(relays, cadence, dateStr, namespace, agent, myEventId, queryLocks, raceCheckDelayMs, error, log) {
|
||||
await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs));
|
||||
|
||||
const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const racingLocks = postLocks
|
||||
.filter((l) => l.agent === agent)
|
||||
.sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId)));
|
||||
|
||||
if (racingLocks.length > 1 && racingLocks[0].eventId !== myEventId) {
|
||||
const winner = racingLocks[0];
|
||||
error(
|
||||
`RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
|
||||
);
|
||||
log('LOCK_STATUS=race_lost');
|
||||
log('LOCK_REASON=earlier_claim_exists');
|
||||
log(`LOCK_WINNER_EVENT=${winner.eventId}`);
|
||||
throw new ExitError(3, 'Race check lost');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to acquire an exclusive lock for an agent on the specified cadence.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Validate agent against the roster.
|
||||
* 2. Query relays for existing valid locks (checking for conflicts).
|
||||
* 3. Generate a new ephemeral keypair and build a lock event (kind 30078).
|
||||
* 4. Publish the lock event to relays.
|
||||
* 5. Wait for propagation (raceCheckDelayMs) and re-query to confirm no earlier lock won the race.
|
||||
*
|
||||
* @param {string} agent - Agent name
|
||||
* @param {string} cadence - 'daily' or 'weekly'
|
||||
* @param {boolean} [dryRun=false] - If true, skips publishing
|
||||
* @param {Object} [deps] - Dependency injection
|
||||
* @returns {Promise<{status: string, eventId: string}>}
|
||||
* @throws {ExitError} If lock is denied (already locked, completed, or race lost)
|
||||
*/
|
||||
export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}) {
|
||||
const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun };
|
||||
const { dryRun = false, platform = null, model = null } = options;
|
||||
|
||||
const {
|
||||
getRelays = _getRelays,
|
||||
getNamespace = _getNamespace,
|
||||
getHashtag = _getHashtag,
|
||||
getTtl = _getTtl,
|
||||
queryLocks = _queryLocks,
|
||||
getRoster = _getRoster,
|
||||
publishLock = _publishLock,
|
||||
generateSecretKey = _generateSecretKey,
|
||||
getPublicKey = _getPublicKey,
|
||||
finalizeEvent = _finalizeEvent,
|
||||
raceCheckDelayMs = RACE_CHECK_DELAY_MS,
|
||||
getDateStr = todayDateStr,
|
||||
log = console.log,
|
||||
error = console.error
|
||||
} = deps;
|
||||
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const hashtag = await getHashtag();
|
||||
const dateStr = getDateStr();
|
||||
const ttl = await getTtl();
|
||||
const now = nowUnix();
|
||||
const expiresAt = now + ttl;
|
||||
|
||||
const gitCommit = getGitCommit();
|
||||
const { promptHash, promptPath } = await getPromptInfo(agent, cadence);
|
||||
|
||||
error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
|
||||
error(`Hashtag: #${hashtag}`);
|
||||
error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
|
||||
error(`Relays: ${relays.join(', ')}`);
|
||||
if (gitCommit) error(`Git Commit: ${gitCommit}`);
|
||||
if (promptHash) error(`Prompt Hash: ${promptHash.slice(0, 12)}...`);
|
||||
|
||||
const roster = await getRoster(cadence);
|
||||
validateRoster(agent, roster, cadence, error);
|
||||
|
||||
error('Step 1: Checking for existing locks...');
|
||||
const existingLocks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
checkExistingLocks(existingLocks, agent, error, log);
|
||||
|
||||
error('Step 2: Generating ephemeral keypair...');
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
|
||||
|
||||
error('Step 3: Building lock event...');
|
||||
const event = buildLockEvent(agent, cadence, namespace, hashtag, dateStr, now, expiresAt, platform, model, gitCommit, promptPath, promptHash, finalizeEvent, sk);
|
||||
|
||||
error(` Event ID: ${event.id}`);
|
||||
|
||||
if (dryRun) {
|
||||
error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
|
||||
error('RACE CHECK: won (dry run — no real contention possible)');
|
||||
} else {
|
||||
error('Step 4: Publishing to relays...');
|
||||
await publishLock(relays, event);
|
||||
|
||||
error('Step 5: Race check...');
|
||||
await performRaceCheck(relays, cadence, dateStr, namespace, agent, event.id, queryLocks, raceCheckDelayMs, error, log);
|
||||
|
||||
error('RACE CHECK: won');
|
||||
}
|
||||
|
||||
log('LOCK_STATUS=ok');
|
||||
log(`LOCK_EVENT_ID=${event.id}`);
|
||||
log(`LOCK_PUBKEY=${pk}`);
|
||||
log(`LOCK_AGENT=${agent}`);
|
||||
log(`LOCK_HASHTAG=${hashtag}`);
|
||||
log(`LOCK_CADENCE=${cadence}`);
|
||||
log(`LOCK_DATE=${dateStr}`);
|
||||
log(`LOCK_EXPIRES=${expiresAt}`);
|
||||
log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
|
||||
if (gitCommit) log(`LOCK_GIT_COMMIT=${gitCommit}`);
|
||||
if (promptHash) log(`LOCK_PROMPT_HASH=${promptHash}`);
|
||||
return { status: 'ok', eventId: event.id };
|
||||
}
|
||||
+54
-37
@@ -1,99 +1,116 @@
|
||||
import { createProposal, listProposals, applyProposal, rejectProposal, getProposal } from './services/governance/index.js';
|
||||
import {
|
||||
createProposal as _createProposal,
|
||||
listProposals as _listProposals,
|
||||
applyProposal as _applyProposal,
|
||||
rejectProposal as _rejectProposal,
|
||||
getProposal as _getProposal
|
||||
} from './services/governance/index.js';
|
||||
import { ExitError } from './errors.mjs';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export async function cmdProposal(subcommand, args = {}) {
|
||||
export async function cmdProposal(subcommand, args = {}, deps = {}) {
|
||||
const {
|
||||
createProposal = _createProposal,
|
||||
listProposals = _listProposals,
|
||||
applyProposal = _applyProposal,
|
||||
rejectProposal = _rejectProposal,
|
||||
getProposal = _getProposal,
|
||||
readFile = fs.readFile,
|
||||
log = console.log,
|
||||
error = console.error
|
||||
} = deps;
|
||||
|
||||
switch (subcommand) {
|
||||
case 'create':
|
||||
return await handleCreate(args);
|
||||
return await handleCreate(args, { createProposal, readFile, log, error });
|
||||
case 'list':
|
||||
return await handleList(args);
|
||||
return await handleList(args, { listProposals, log, error });
|
||||
case 'apply':
|
||||
return await handleApply(args);
|
||||
return await handleApply(args, { applyProposal, log, error });
|
||||
case 'reject':
|
||||
return await handleReject(args);
|
||||
return await handleReject(args, { rejectProposal, log, error });
|
||||
case 'show':
|
||||
return await handleShow(args);
|
||||
return await handleShow(args, { getProposal, log, error });
|
||||
default:
|
||||
console.error(`Unknown proposal subcommand: ${subcommand}`);
|
||||
error(`Unknown proposal subcommand: ${subcommand}`);
|
||||
throw new ExitError(1, 'Unknown subcommand');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate({ agent, target, contentFile, reason }) {
|
||||
async function handleCreate({ agent, target, contentFile, reason }, { createProposal, readFile, log, error }) {
|
||||
if (!agent || !target || !contentFile || !reason) {
|
||||
console.error('Usage: torch-lock proposal create --agent <name> --target <path> --content <file> --reason <text>');
|
||||
error('Usage: torch-lock proposal create --agent <name> --target <path> --content <file> --reason <text>');
|
||||
throw new ExitError(1, 'Missing arguments');
|
||||
}
|
||||
|
||||
let newContent;
|
||||
try {
|
||||
newContent = await fs.readFile(contentFile, 'utf8');
|
||||
newContent = await readFile(contentFile, 'utf8');
|
||||
} catch (_e) {
|
||||
console.error(`Failed to read content file: ${contentFile}`);
|
||||
error(`Failed to read content file: ${contentFile}`);
|
||||
throw new ExitError(1, 'File read error');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createProposal({ agent, target, newContent, reason });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
log(JSON.stringify(result, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Failed to create proposal: ${e.message}`);
|
||||
error(`Failed to create proposal: ${e.message}`);
|
||||
throw new ExitError(1, 'Proposal creation failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList({ status }) {
|
||||
async function handleList({ status }, { listProposals, log, error }) {
|
||||
try {
|
||||
const proposals = await listProposals();
|
||||
const filtered = status ? proposals.filter(p => p.status === status) : proposals;
|
||||
console.log(JSON.stringify(filtered, null, 2));
|
||||
log(JSON.stringify(filtered, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Failed to list proposals: ${e.message}`);
|
||||
error(`Failed to list proposals: ${e.message}`);
|
||||
throw new ExitError(1, 'List failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply({ id }) {
|
||||
async function handleApply({ id }, { applyProposal, log, error }) {
|
||||
if (!id) {
|
||||
console.error('Usage: torch-lock proposal apply --id <proposal-id>');
|
||||
error('Usage: torch-lock proposal apply --id <proposal-id>');
|
||||
throw new ExitError(1, 'Missing id');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await applyProposal(id);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
log(JSON.stringify(result, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Failed to apply proposal: ${e.message}`);
|
||||
error(`Failed to apply proposal: ${e.message}`);
|
||||
throw new ExitError(1, 'Apply failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject({ id, reason }) {
|
||||
async function handleReject({ id, reason }, { rejectProposal, log, error }) {
|
||||
if (!id || !reason) {
|
||||
console.error('Usage: torch-lock proposal reject --id <proposal-id> --reason <text>');
|
||||
error('Usage: torch-lock proposal reject --id <proposal-id> --reason <text>');
|
||||
throw new ExitError(1, 'Missing arguments');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await rejectProposal(id, reason);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
log(JSON.stringify(result, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Failed to reject proposal: ${e.message}`);
|
||||
error(`Failed to reject proposal: ${e.message}`);
|
||||
throw new ExitError(1, 'Reject failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShow({ id }) {
|
||||
if (!id) {
|
||||
console.error('Usage: torch-lock proposal show --id <proposal-id>');
|
||||
throw new ExitError(1, 'Missing id');
|
||||
}
|
||||
try {
|
||||
const proposal = await getProposal(id);
|
||||
console.log(JSON.stringify(proposal, null, 2));
|
||||
} catch (e) {
|
||||
console.error(`Failed to show proposal: ${e.message}`);
|
||||
throw new ExitError(1, 'Show failed');
|
||||
}
|
||||
async function handleShow({ id }, { getProposal, log, error }) {
|
||||
if (!id) {
|
||||
error('Usage: torch-lock proposal show --id <proposal-id>');
|
||||
throw new ExitError(1, 'Missing id');
|
||||
}
|
||||
try {
|
||||
const proposal = await getProposal(id);
|
||||
log(JSON.stringify(proposal, null, 2));
|
||||
} catch (e) {
|
||||
error(`Failed to show proposal: ${e.message}`);
|
||||
throw new ExitError(1, 'Show failed');
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ Commands:
|
||||
init [--force] Initialize torch/ directory in current project
|
||||
update [--force] Update torch/ configuration (backups, merges)
|
||||
remove [--force] Remove all TORCH files and configuration from project
|
||||
backup [--list] [--output <path>] Snapshot runtime state to .torch/backups/
|
||||
proposal <create|list|apply|reject|show> [...] Manage prompt change proposals
|
||||
rollback --target <file> [--list] Roll back a prompt to a previous version
|
||||
|
||||
list-memories [--agent <id>] [--type <type>] [--tags <a,b>] [--pinned <true|false>] [--full]
|
||||
inspect-memory --id <memoryId>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
agent: content-audit-agent
|
||||
cadence: daily
|
||||
session: 2026-02-21T18-44-11Z
|
||||
run-start: 2026-02-21T18-44-11Z
|
||||
prompt: src/prompts/daily/content-audit-agent.md
|
||||
---
|
||||
# Context Note — Content Audit — 2026-02-21
|
||||
|
||||
## Goal
|
||||
Verify documentation accuracy against repository implementation.
|
||||
|
||||
## Scope
|
||||
- `README.md`
|
||||
- `TORCH.md`
|
||||
- `bin/torch-lock.mjs`
|
||||
- `src/constants.mjs`
|
||||
|
||||
## Findings
|
||||
- `USAGE_TEXT` in `src/constants.mjs` was missing `proposal`, `rollback`, and `backup` commands, despite them being implemented and documented.
|
||||
- `README.md` correctly lists these commands.
|
||||
- `npm run validate:docs` passed.
|
||||
|
||||
## Constraints
|
||||
- Limited to correcting specific inaccuracies.
|
||||
+73
-1
@@ -31,16 +31,72 @@ function timingSafeCompare(a, b) {
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
function verifyPassword(input, stored) {
|
||||
if (stored.startsWith('scrypt:')) {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
try {
|
||||
const salt = Buffer.from(parts[1], 'hex');
|
||||
const hash = Buffer.from(parts[2], 'hex');
|
||||
const derived = crypto.scryptSync(input, salt, 64);
|
||||
return crypto.timingSafeEqual(derived, hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return timingSafeCompare(input, stored);
|
||||
}
|
||||
|
||||
export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.0.1') {
|
||||
// Resolve package root relative to this file (src/dashboard.mjs)
|
||||
// this file is in <root>/src/dashboard.mjs, so '..' goes to <root>
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const auth = await getDashboardAuth();
|
||||
|
||||
// Rate limiting state
|
||||
const MAX_AUTH_ATTEMPTS = 5;
|
||||
const BLOCK_DURATION_MS = 300000; // 5 minutes
|
||||
const CLEANUP_INTERVAL_MS = 600000; // 10 minutes
|
||||
const rateLimit = new Map(); // ip -> { attempts, blockExpires, lastSeen }
|
||||
|
||||
// Cleanup old entries
|
||||
const cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, data] of rateLimit.entries()) {
|
||||
if (data.blockExpires && data.blockExpires < now) {
|
||||
rateLimit.delete(ip);
|
||||
} else if (!data.blockExpires && (now - data.lastSeen > CLEANUP_INTERVAL_MS)) {
|
||||
rateLimit.delete(ip);
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
// Basic Auth check
|
||||
if (auth) {
|
||||
const clientIp = req.socket.remoteAddress;
|
||||
const now = Date.now();
|
||||
|
||||
// Check rate limit
|
||||
const clientState = rateLimit.get(clientIp) || { attempts: 0, blockExpires: 0, lastSeen: now };
|
||||
|
||||
// Update last seen
|
||||
clientState.lastSeen = now;
|
||||
|
||||
if (clientState.blockExpires > now) {
|
||||
res.writeHead(429, {
|
||||
...SECURITY_HEADERS,
|
||||
'Retry-After': Math.ceil((clientState.blockExpires - now) / 1000)
|
||||
});
|
||||
res.end('Too Many Requests');
|
||||
return;
|
||||
} else if (clientState.blockExpires) {
|
||||
// Block expired, reset state
|
||||
clientState.attempts = 0;
|
||||
clientState.blockExpires = 0;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
|
||||
@@ -54,17 +110,29 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
|
||||
if (type === 'Basic' && credentials) {
|
||||
try {
|
||||
const decoded = Buffer.from(credentials, 'base64').toString();
|
||||
isValid = timingSafeCompare(decoded, auth);
|
||||
isValid = verifyPassword(decoded, auth);
|
||||
} catch {
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
// Increment attempts
|
||||
clientState.attempts += 1;
|
||||
if (clientState.attempts >= MAX_AUTH_ATTEMPTS) {
|
||||
clientState.blockExpires = Date.now() + BLOCK_DURATION_MS;
|
||||
}
|
||||
rateLimit.set(clientIp, clientState);
|
||||
|
||||
res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' });
|
||||
res.end('Invalid credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset attempts on success
|
||||
if (rateLimit.has(clientIp)) {
|
||||
rateLimit.delete(clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
// URL parsing
|
||||
@@ -219,6 +287,10 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
|
||||
}
|
||||
});
|
||||
|
||||
server.on('close', () => {
|
||||
clearInterval(cleanupInterval);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, host, () => {
|
||||
const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
agent: content-audit-agent
|
||||
cadence: daily
|
||||
session: 2026-02-21T18-44-11Z
|
||||
run-start: 2026-02-21T18-44-11Z
|
||||
prompt: src/prompts/daily/content-audit-agent.md
|
||||
---
|
||||
# Decision Note — Content Audit — 2026-02-21
|
||||
|
||||
## Update `src/constants.mjs` Usage Text
|
||||
**Decision:** Add `proposal`, `rollback`, and `backup` commands to `USAGE_TEXT`.
|
||||
**Rationale:** The implementation exists in `src/lib.mjs` and commands are documented in `README.md`. The CLI help text was incomplete, leading to potential user confusion.
|
||||
+2
-191
@@ -10,13 +10,11 @@ import {
|
||||
loadTorchConfig as _loadTorchConfig,
|
||||
getRelays as _getRelays,
|
||||
getNamespace as _getNamespace,
|
||||
getTtl as _getTtl,
|
||||
getHashtag as _getHashtag,
|
||||
} from './torch-config.mjs';
|
||||
import {
|
||||
VALID_CADENCES,
|
||||
KIND_APP_DATA,
|
||||
RACE_CHECK_DELAY_MS,
|
||||
USAGE_TEXT,
|
||||
MS_PER_SECOND,
|
||||
} from './constants.mjs';
|
||||
@@ -38,16 +36,15 @@ import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs';
|
||||
import { runRelayHealthCheck } from './relay-health.mjs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { getCompletedAgents } from './lock-utils.mjs';
|
||||
import { cmdProposal } from './cmd-proposal.mjs';
|
||||
import { cmdRollback } from './cmd-rollback.mjs';
|
||||
import { cmdLock } from './cmd-lock.mjs';
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
// Re-export for backward compatibility/library usage
|
||||
export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock };
|
||||
export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock, cmdLock };
|
||||
|
||||
/**
|
||||
* Checks the status of the repository locks for a given cadence.
|
||||
@@ -147,192 +144,6 @@ export async function cmdCheck(cadence, deps = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to acquire an exclusive lock for an agent on the specified cadence.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Validate agent against the roster.
|
||||
* 2. Query relays for existing valid locks (checking for conflicts).
|
||||
* 3. Generate a new ephemeral keypair and build a lock event (kind 30078).
|
||||
* 4. Publish the lock event to relays.
|
||||
* 5. Wait for propagation (raceCheckDelayMs) and re-query to confirm no earlier lock won the race.
|
||||
*
|
||||
* @param {string} agent - Agent name
|
||||
* @param {string} cadence - 'daily' or 'weekly'
|
||||
* @param {boolean} [dryRun=false] - If true, skips publishing
|
||||
* @param {Object} [deps] - Dependency injection
|
||||
* @returns {Promise<{status: string, eventId: string}>}
|
||||
* @throws {ExitError} If lock is denied (already locked, completed, or race lost)
|
||||
*/
|
||||
export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}) {
|
||||
const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun };
|
||||
const { dryRun = false, platform = null, model = null } = options;
|
||||
|
||||
const {
|
||||
getRelays = _getRelays,
|
||||
getNamespace = _getNamespace,
|
||||
getHashtag = _getHashtag,
|
||||
getTtl = _getTtl,
|
||||
queryLocks = _queryLocks,
|
||||
getRoster = _getRoster,
|
||||
publishLock = _publishLock,
|
||||
generateSecretKey = _generateSecretKey,
|
||||
getPublicKey = _getPublicKey,
|
||||
finalizeEvent = _finalizeEvent,
|
||||
raceCheckDelayMs = RACE_CHECK_DELAY_MS,
|
||||
getDateStr = todayDateStr,
|
||||
log = console.log,
|
||||
error = console.error
|
||||
} = deps;
|
||||
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const hashtag = await getHashtag();
|
||||
const dateStr = getDateStr();
|
||||
const ttl = await getTtl();
|
||||
const now = nowUnix();
|
||||
const expiresAt = now + ttl;
|
||||
|
||||
let gitCommit = null;
|
||||
try {
|
||||
gitCommit = execSync('git rev-parse HEAD', { stdio: 'pipe' }).toString().trim();
|
||||
} catch {
|
||||
// Ignore git errors (not a git repo, etc)
|
||||
}
|
||||
|
||||
let promptHash = null;
|
||||
let promptPath = null;
|
||||
try {
|
||||
// Attempt to locate the prompt file based on standard structure
|
||||
// We assume the CWD is the project root or we are in a standard structure.
|
||||
// If not found, we just omit the hash.
|
||||
const potentialPath = path.join(process.cwd(), 'src', 'prompts', cadence, `${agent}.md`);
|
||||
const content = await fs.readFile(potentialPath, 'utf8');
|
||||
promptHash = createHash('sha256').update(content).digest('hex');
|
||||
promptPath = `src/prompts/${cadence}/${agent}.md`;
|
||||
} catch {
|
||||
// Ignore missing prompt file
|
||||
}
|
||||
|
||||
error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
|
||||
error(`Hashtag: #${hashtag}`);
|
||||
error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
|
||||
error(`Relays: ${relays.join(', ')}`);
|
||||
if (gitCommit) error(`Git Commit: ${gitCommit}`);
|
||||
if (promptHash) error(`Prompt Hash: ${promptHash.slice(0, 12)}...`);
|
||||
|
||||
const roster = await getRoster(cadence);
|
||||
if (!roster.includes(agent)) {
|
||||
error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
|
||||
error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
|
||||
throw new ExitError(1, 'Agent not in roster');
|
||||
}
|
||||
|
||||
error('Step 1: Checking for existing locks...');
|
||||
const existingLocks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const conflicting = existingLocks.filter((l) => l.agent === agent);
|
||||
|
||||
if (conflicting.length > 0) {
|
||||
const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||
|
||||
// Check if it is a completed task
|
||||
if (earliest.status === 'completed') {
|
||||
error(`LOCK DENIED: Task already completed by event ${earliest.eventId}`);
|
||||
log('LOCK_STATUS=denied');
|
||||
log('LOCK_REASON=already_completed');
|
||||
throw new ExitError(3, 'Task already completed');
|
||||
}
|
||||
|
||||
error(
|
||||
`LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
|
||||
`(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
|
||||
);
|
||||
log('LOCK_STATUS=denied');
|
||||
log('LOCK_REASON=already_locked');
|
||||
log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
|
||||
throw new ExitError(3, 'Lock denied');
|
||||
}
|
||||
|
||||
error('Step 2: Generating ephemeral keypair...');
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
|
||||
|
||||
error('Step 3: Building lock event...');
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: KIND_APP_DATA,
|
||||
created_at: now,
|
||||
tags: [
|
||||
['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
|
||||
['t', hashtag],
|
||||
['t', `${namespace}-lock-${cadence}`],
|
||||
['t', `${namespace}-lock-${cadence}-${dateStr}`],
|
||||
['expiration', String(expiresAt)],
|
||||
],
|
||||
content: JSON.stringify({
|
||||
agent,
|
||||
cadence,
|
||||
status: 'started',
|
||||
namespace,
|
||||
date: dateStr,
|
||||
platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown',
|
||||
model: model || process.env.AGENT_MODEL || 'unknown',
|
||||
lockedAt: new Date(now * MS_PER_SECOND).toISOString(),
|
||||
expiresAt: new Date(expiresAt * MS_PER_SECOND).toISOString(),
|
||||
gitCommit,
|
||||
promptPath,
|
||||
promptHash,
|
||||
}),
|
||||
},
|
||||
sk,
|
||||
);
|
||||
|
||||
error(` Event ID: ${event.id}`);
|
||||
|
||||
if (dryRun) {
|
||||
error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
|
||||
error('RACE CHECK: won (dry run — no real contention possible)');
|
||||
} else {
|
||||
error('Step 4: Publishing to relays...');
|
||||
await publishLock(relays, event);
|
||||
|
||||
error('Step 5: Race check...');
|
||||
await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs));
|
||||
|
||||
const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const racingLocks = postLocks
|
||||
.filter((l) => l.agent === agent)
|
||||
.sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId)));
|
||||
|
||||
if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) {
|
||||
const winner = racingLocks[0];
|
||||
error(
|
||||
`RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
|
||||
);
|
||||
log('LOCK_STATUS=race_lost');
|
||||
log('LOCK_REASON=earlier_claim_exists');
|
||||
log(`LOCK_WINNER_EVENT=${winner.eventId}`);
|
||||
throw new ExitError(3, 'Race check lost');
|
||||
}
|
||||
|
||||
error('RACE CHECK: won');
|
||||
}
|
||||
|
||||
log('LOCK_STATUS=ok');
|
||||
log(`LOCK_EVENT_ID=${event.id}`);
|
||||
log(`LOCK_PUBKEY=${pk}`);
|
||||
log(`LOCK_AGENT=${agent}`);
|
||||
log(`LOCK_HASHTAG=${hashtag}`);
|
||||
log(`LOCK_CADENCE=${cadence}`);
|
||||
log(`LOCK_DATE=${dateStr}`);
|
||||
log(`LOCK_EXPIRES=${expiresAt}`);
|
||||
log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * MS_PER_SECOND).toISOString()}`);
|
||||
if (gitCommit) log(`LOCK_GIT_COMMIT=${gitCommit}`);
|
||||
if (promptHash) log(`LOCK_PROMPT_HASH=${promptHash}`);
|
||||
return { status: 'ok', eventId: event.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all active locks for the specified cadence (or all cadences if null).
|
||||
* It prints a formatted table to stdout with lock age, TTL, and event ID.
|
||||
|
||||
+54
-129
@@ -6,100 +6,14 @@ import {
|
||||
getRelayFallbacks,
|
||||
getMinActiveRelayPool,
|
||||
} from './torch-config.mjs';
|
||||
import { mergeRelayList, relayListLabel, withTimeout } from './utils.mjs';
|
||||
import { relayListLabel, withTimeout, mergeRelayList } from './utils.mjs';
|
||||
import { defaultHealthManager, buildRelayHealthConfig } from './relay-health-manager.mjs';
|
||||
import { secureRandom } from './lock-utils.mjs';
|
||||
|
||||
const PUBLISH_ERROR_CODES = {
|
||||
TIMEOUT: 'publish_timeout',
|
||||
DNS: 'dns_resolution',
|
||||
TCP: 'tcp_connect_timeout',
|
||||
TLS: 'tls_handshake',
|
||||
WEBSOCKET: 'websocket_open_failure',
|
||||
NETWORK: 'network_timeout',
|
||||
CONNECTION_RESET: 'connection_reset',
|
||||
RELAY_UNAVAILABLE: 'relay_unavailable',
|
||||
PERMANENT: 'permanent_validation_error',
|
||||
};
|
||||
|
||||
const PUBLISH_FAILURE_CATEGORIES = {
|
||||
QUORUM_FAILURE: 'relay_publish_quorum_failure',
|
||||
NON_RETRYABLE: 'relay_publish_non_retryable',
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies a raw error message into a standardized publication error code.
|
||||
* Used to determine if a failure is transient (retryable) or permanent.
|
||||
*
|
||||
* @param {string|Error} message - The error message or object to classify.
|
||||
* @returns {string} One of the PUBLISH_ERROR_CODES constants.
|
||||
*/
|
||||
function classifyPublishError(message) {
|
||||
const normalized = String(message || '').toLowerCase();
|
||||
if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) {
|
||||
return PUBLISH_ERROR_CODES.TIMEOUT;
|
||||
}
|
||||
if (
|
||||
normalized.includes('enotfound')
|
||||
|| normalized.includes('eai_again')
|
||||
|| normalized.includes('getaddrinfo')
|
||||
|| (normalized.includes('dns') && normalized.includes('websocket'))
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.DNS;
|
||||
}
|
||||
if (
|
||||
normalized.includes('connect etimedout')
|
||||
|| normalized.includes('tcp connect timed out')
|
||||
|| normalized.includes('connect timeout')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.TCP;
|
||||
}
|
||||
if (
|
||||
normalized.includes('tls')
|
||||
|| normalized.includes('ssl')
|
||||
|| normalized.includes('certificate')
|
||||
|| normalized.includes('handshake')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.TLS;
|
||||
}
|
||||
if (
|
||||
normalized.includes('websocket')
|
||||
|| normalized.includes('bad response')
|
||||
|| normalized.includes('unexpected server response')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.WEBSOCKET;
|
||||
}
|
||||
if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) {
|
||||
return PUBLISH_ERROR_CODES.NETWORK;
|
||||
}
|
||||
if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) {
|
||||
return PUBLISH_ERROR_CODES.CONNECTION_RESET;
|
||||
}
|
||||
if (
|
||||
normalized.includes('unavailable')
|
||||
|| normalized.includes('offline')
|
||||
|| normalized.includes('econnrefused')
|
||||
|| normalized.includes('connection refused')
|
||||
|| normalized.includes('enotfound')
|
||||
|| normalized.includes('503')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE;
|
||||
}
|
||||
return PUBLISH_ERROR_CODES.PERMANENT;
|
||||
}
|
||||
|
||||
function isTransientPublishCategory(category) {
|
||||
return [
|
||||
PUBLISH_ERROR_CODES.TIMEOUT,
|
||||
PUBLISH_ERROR_CODES.DNS,
|
||||
PUBLISH_ERROR_CODES.TCP,
|
||||
PUBLISH_ERROR_CODES.TLS,
|
||||
PUBLISH_ERROR_CODES.WEBSOCKET,
|
||||
PUBLISH_ERROR_CODES.NETWORK,
|
||||
PUBLISH_ERROR_CODES.CONNECTION_RESET,
|
||||
PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE,
|
||||
].includes(category);
|
||||
}
|
||||
import {
|
||||
PUBLISH_FAILURE_CATEGORIES,
|
||||
classifyPublishError,
|
||||
isTransientPublishCategory,
|
||||
} from './lock-error-classifier.mjs';
|
||||
|
||||
function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) {
|
||||
const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1)));
|
||||
@@ -187,6 +101,17 @@ export class LockPublisher {
|
||||
* @throws {Error} If publication quorum is not met after all attempts.
|
||||
*/
|
||||
async publish() {
|
||||
this._initialize();
|
||||
|
||||
try {
|
||||
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic');
|
||||
return await this._executeRetryLoop(Date.now());
|
||||
} finally {
|
||||
this.pool.close(this.allRelays);
|
||||
}
|
||||
}
|
||||
|
||||
_initialize() {
|
||||
const {
|
||||
poolFactory = () => new SimplePool(),
|
||||
retryAttempts = 4,
|
||||
@@ -222,49 +147,49 @@ export class LockPublisher {
|
||||
this.attemptId = diagnostics.attemptId || randomUUID();
|
||||
|
||||
this.allRelays = mergeRelayList(this.relays, this.fallbackRelays);
|
||||
}
|
||||
|
||||
try {
|
||||
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic');
|
||||
let lastAttemptState = null;
|
||||
const retryTimeline = [];
|
||||
const overallStartedAt = Date.now();
|
||||
let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
|
||||
async _executeRetryLoop(overallStartedAt) {
|
||||
let lastAttemptState = null;
|
||||
const retryTimeline = [];
|
||||
let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
|
||||
|
||||
// Retry loop: attempts publication until success or max attempts reached
|
||||
for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) {
|
||||
const startedAtMs = Date.now();
|
||||
lastAttemptState = await this.executePublishCycle();
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
retryTimeline.push({
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
elapsedMs,
|
||||
});
|
||||
// Retry loop: attempts publication until success or max attempts reached
|
||||
for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) {
|
||||
const startedAtMs = Date.now();
|
||||
lastAttemptState = await this.executePublishCycle();
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
retryTimeline.push({
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
elapsedMs,
|
||||
});
|
||||
|
||||
if (lastAttemptState.successCount >= this.minSuccesses) {
|
||||
this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt);
|
||||
return this.event;
|
||||
}
|
||||
|
||||
const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures);
|
||||
const canRetry = attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure;
|
||||
|
||||
terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber);
|
||||
|
||||
if (!canRetry) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn);
|
||||
this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs);
|
||||
await this.sleepFn(nextDelayMs);
|
||||
if (lastAttemptState.successCount >= this.minSuccesses) {
|
||||
this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt);
|
||||
return this.event;
|
||||
}
|
||||
|
||||
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
|
||||
} finally {
|
||||
this.pool.close(this.allRelays);
|
||||
const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures);
|
||||
const canRetry = this._shouldRetry(attemptNumber, hasTransientFailure, hasPermanentFailure);
|
||||
|
||||
terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber);
|
||||
|
||||
if (!canRetry) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn);
|
||||
this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs);
|
||||
await this.sleepFn(nextDelayMs);
|
||||
}
|
||||
|
||||
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
|
||||
}
|
||||
|
||||
_shouldRetry(attemptNumber, hasTransientFailure, hasPermanentFailure) {
|
||||
return attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure;
|
||||
}
|
||||
|
||||
async executePublishCycle() {
|
||||
|
||||
+1
-7
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { todayDateStr, getIsoWeekStr } from './utils.mjs';
|
||||
|
||||
export { relayListLabel } from './utils.mjs';
|
||||
export { relayListLabel, withTimeout, mergeRelayList } from './utils.mjs';
|
||||
|
||||
/**
|
||||
* Scans the local log directory to identify agents that have already completed their task
|
||||
@@ -51,12 +51,6 @@ export async function getCompletedAgents(cadence, logDir, deps) {
|
||||
return completed;
|
||||
}
|
||||
|
||||
export { withTimeout } from './utils.mjs';
|
||||
|
||||
export function mergeRelayList(primaryRelays, fallbackRelays) {
|
||||
return [...new Set([...primaryRelays, ...fallbackRelays])];
|
||||
}
|
||||
|
||||
const MAX_RANDOM = 281474976710655; // 2**48 - 1
|
||||
|
||||
export function secureRandom() {
|
||||
|
||||
@@ -10,17 +10,16 @@
|
||||
|
||||
You are: **perf-agent**, a senior software engineer AI agent working inside this repository (default branch).
|
||||
|
||||
Mission: **daily, measurable improvement of app responsiveness** by finding and fixing background CPU/network work that degrades UX — and ensuring user-facing docs (`/content`) match runtime upload/contribution behavior. Make small, safe, incremental changes. Every change must be traceable, tested or manually verified, and documented.
|
||||
Mission: **daily, measurable improvement of app responsiveness** by finding and fixing background CPU/network work that degrades UX. Make small, safe, incremental changes. Every change must be traceable, tested or manually verified, and documented.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
IDENTITY & MANDATE
|
||||
- Role: senior engineer agent (implementer + doc auditor).
|
||||
- Role: senior engineer agent.
|
||||
- Repo: this repository — default branch.
|
||||
- Focus (priority):
|
||||
- **P0**: Login/auth, relay initialization & profile hydration, decryption of user lists (hashtags, subscriptions, blocks, watch history, moderation lists).
|
||||
- **P1**: Anything affecting initial UI responsiveness (profile cache, relay load).
|
||||
- **P2**: User-initiated heavy features (WebTorrent playback) — lazy-init/deprioritize until P0/P1 resolved.
|
||||
- **Docs**: `/content` — keep upload/contribution docs accurate and runnable.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
PRIMARY GOALS & SUCCESS CRITERIA
|
||||
@@ -28,11 +27,9 @@ PRIMARY GOALS & SUCCESS CRITERIA
|
||||
1. Finds expensive background patterns (main-thread loops, unbounded concurrency, eager socket/torrent creation).
|
||||
2. Applies small safety-first fixes (visibility-gating, bounded concurrency, workerization, lazy-init).
|
||||
3. Keeps login/auth and decryption flows fast and reliable.
|
||||
4. Verifies and updates `/content` so user docs are accurate and executable.
|
||||
- Success:
|
||||
- Login/auth & profile hydration not blocked by background tasks.
|
||||
- Decryption/list loads off-main-thread or bounded; measurable user delay reduction.
|
||||
- `/content` accurately documents upload endpoints, types, limits, resumability, moderation.
|
||||
- Every fix is a small PR with files in `src/context/`, `src/todo/`, `src/decisions/`, `src/test_logs/` and tests/QA steps.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
@@ -79,10 +76,7 @@ DAILY WORKFLOW (run each day)
|
||||
- Make the smallest change that improves UX; add tests or manual QA steps.
|
||||
- If risky, gate behind feature flags in `src/constants.js`.
|
||||
|
||||
5. **Docs audit**
|
||||
- If upload/ingest code or `/content` is touched, run the docs audit workflow (inventory → verify → update → validate → deliver). Always leave `/content` runnable/precise.
|
||||
|
||||
6. **Report**
|
||||
5. **Report**
|
||||
- Produce `reports/performance/daily-perf-report-YYYY-MM-DD.md` summarizing findings, PRs/issues, metrics, and blockers.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
@@ -119,19 +113,9 @@ SMALL SAFE FIX EXAMPLES (PRs)
|
||||
- Add `MAX_DM_WORKER_PENDING` to `dmDecryptWorkerClient.js`. Throttle/reject when exceeded; log `getDmDecryptWorkerQueueSize()`.
|
||||
- **perf: visibility-gate ambient RAF & canvas**
|
||||
- Ensure RAF loops check `document.hidden` and avoid multiple attachments.
|
||||
- **docs: `/content` upload alignment**
|
||||
- Audit `/content`, update exact accepted types, size limits, endpoints, resumability examples (curl/JS).
|
||||
|
||||
All PRs: small, feature-flagged if risky, include `src/context/CONTEXT_<timestamp>.md`, `src/test_logs/TEST_LOG_<timestamp>.md`, `src/decisions/DECISIONS_<timestamp>.md`.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
DOCS AUDIT (integrated sub-workflow — mandatory when doc/code touched)
|
||||
1. INVENTORY: list `/content` pages that document uploading/contribution. Extract concrete claims and map to code locations.
|
||||
2. VERIFY: confirm claims against frontend components, API handlers, storage/processing pipelines, and build/deploy. Pay attention to MIME lists, size limits, resumability, error messages, moderation.
|
||||
3. UPDATE: make `/content` exact and copy-pastable. If code is wrong, either fix code (small) or document divergence and open issue.
|
||||
4. VALIDATE: run end-to-end uploads where possible; capture terminal logs, curl responses, screenshots (redact secrets).
|
||||
5. DELIVER: open PR titled `"docs: align /content user docs with actual upload & contribution behavior"` with validation artifacts and a contributor-facing note.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
WORK LOOP & VERIFICATION (for code changes)
|
||||
A) PLAN: 3–7 bullet plan before coding.
|
||||
@@ -162,10 +146,6 @@ PR & ISSUE GUIDELINES (what to include)
|
||||
- QA steps & manual validation instructions.
|
||||
- Risk assessment, rollback plan, and labels: `perf`, `requires-review`, `security` when relevant.
|
||||
|
||||
Docs PR:
|
||||
- Title: `"🌐 Align /content user docs with actual upload & contribution behavior"`
|
||||
- Body: summary, diffs, validation artifacts, note for contributors.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
- If no work is required, exit without making changes.
|
||||
ESCALATION & RISK POLICY
|
||||
@@ -178,8 +158,7 @@ FIRST-RUN CHECKLIST (execute now)
|
||||
- Commit files in `src/context/`, `src/todo/`, `src/decisions/`, `src/test_logs/`, `reports/performance/INITIAL_BASELINE.md`.
|
||||
2. Run Search Patterns across repo and save `reports/performance/hits-YYYY-MM-DD.json`.
|
||||
3. Prioritize P0 findings, open at most 1–3 small PRs (bounded concurrency, worker queue limits, visibility gating).
|
||||
4. Audit `/content` upload pages: inventory claims → map to code → verify → open docs PR or issue.
|
||||
5. Produce `reports/performance/daily-perf-report-YYYY-MM-DD.md` summarizing results and PR links.
|
||||
4. Produce `reports/performance/daily-perf-report-YYYY-MM-DD.md` summarizing results and PR links.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
@@ -196,7 +175,6 @@ OUTPUTS (per run)
|
||||
- PRs opened / Issues opened.
|
||||
- Blockers & human decisions requested.
|
||||
- For every PR: branch contains files in `src/context/`, `src/todo/`, `src/decisions/`, `src/test_logs/`.
|
||||
- For docs PR: verification artifacts attached.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
BEHAVIORAL GUARDRAILS & QUALITY BAR
|
||||
|
||||
+44
-19
@@ -70,33 +70,58 @@ const FALLBACK_ROSTER = {
|
||||
|
||||
let cachedCanonicalRoster = null;
|
||||
|
||||
function loadCanonicalRoster() {
|
||||
async function loadCanonicalRoster() {
|
||||
if (cachedCanonicalRoster) return cachedCanonicalRoster;
|
||||
|
||||
let rosterPath = ROSTER_FILE;
|
||||
let content = null;
|
||||
|
||||
// Prefer user-managed roster if present
|
||||
if (deps.fs.existsSync(USER_ROSTER_FILE)) {
|
||||
const tryRead = async (p) => {
|
||||
try {
|
||||
return { data: await deps.fs.promises.readFile(p, 'utf8'), error: null };
|
||||
} catch (error) {
|
||||
return { data: null, error };
|
||||
}
|
||||
};
|
||||
|
||||
// Try user roster
|
||||
let result = await tryRead(USER_ROSTER_FILE);
|
||||
if (result.data) {
|
||||
rosterPath = USER_ROSTER_FILE;
|
||||
} else if (deps.fs.existsSync(CWD_ROSTER_FILE)) {
|
||||
rosterPath = CWD_ROSTER_FILE;
|
||||
content = result.data;
|
||||
} else if (result.error.code === 'ENOENT') {
|
||||
// Try CWD roster
|
||||
result = await tryRead(CWD_ROSTER_FILE);
|
||||
if (result.data) {
|
||||
rosterPath = CWD_ROSTER_FILE;
|
||||
content = result.data;
|
||||
} else if (result.error.code === 'ENOENT') {
|
||||
// Try default roster
|
||||
result = await tryRead(ROSTER_FILE);
|
||||
if (result.data) {
|
||||
rosterPath = ROSTER_FILE;
|
||||
content = result.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(deps.fs.readFileSync(rosterPath, 'utf8'));
|
||||
const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
const weekly = Array.isArray(parsed.weekly)
|
||||
? parsed.weekly.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (content) {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
const weekly = Array.isArray(parsed.weekly)
|
||||
? parsed.weekly.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (daily.length > 0 && weekly.length > 0) {
|
||||
cachedCanonicalRoster = { daily, weekly };
|
||||
return cachedCanonicalRoster;
|
||||
if (daily.length > 0 && weekly.length > 0) {
|
||||
cachedCanonicalRoster = { daily, weekly };
|
||||
return cachedCanonicalRoster;
|
||||
}
|
||||
|
||||
console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`);
|
||||
} catch {
|
||||
// It's okay if roster file is missing when used as a library/CLI without the file present
|
||||
}
|
||||
|
||||
console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`);
|
||||
} catch {
|
||||
// It's okay if roster file is missing when used as a library/CLI without the file present
|
||||
}
|
||||
|
||||
cachedCanonicalRoster = FALLBACK_ROSTER;
|
||||
@@ -120,7 +145,7 @@ export async function getRoster(cadence) {
|
||||
const config = await deps.loadTorchConfig();
|
||||
const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER);
|
||||
const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER);
|
||||
const canonical = loadCanonicalRoster();
|
||||
const canonical = await loadCanonicalRoster();
|
||||
const dailyFromConfig = config.nostrLock.dailyRoster;
|
||||
const weeklyFromConfig = config.nostrLock.weeklyRoster;
|
||||
|
||||
|
||||
@@ -91,21 +91,26 @@ export function createMemoryCache(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeExpired(now = Date.now()) {
|
||||
for (const [namespaceKey, bucket] of runtimeNamespaces.entries()) {
|
||||
if (!bucket.events.length) continue;
|
||||
function expireNamespace(namespaceKey, now) {
|
||||
const bucket = runtimeNamespaces.get(namespaceKey);
|
||||
if (!bucket || !bucket.events.length) return;
|
||||
|
||||
const originalLength = bucket.events.length;
|
||||
bucket.events = bucket.events.filter((entry) => entry.expiresAt > now);
|
||||
const removed = originalLength - bucket.events.length;
|
||||
if (removed > 0) {
|
||||
totalRuntimeEvents -= removed;
|
||||
metrics.runtime_events_expired += removed;
|
||||
}
|
||||
const originalLength = bucket.events.length;
|
||||
bucket.events = bucket.events.filter((entry) => entry.expiresAt > now);
|
||||
const removed = originalLength - bucket.events.length;
|
||||
if (removed > 0) {
|
||||
totalRuntimeEvents -= removed;
|
||||
metrics.runtime_events_expired += removed;
|
||||
}
|
||||
|
||||
if (bucket.events.length === 0) {
|
||||
runtimeNamespaces.delete(namespaceKey);
|
||||
}
|
||||
if (bucket.events.length === 0) {
|
||||
runtimeNamespaces.delete(namespaceKey);
|
||||
}
|
||||
}
|
||||
|
||||
function expireAllNamespaces(now = Date.now()) {
|
||||
for (const [namespaceKey] of runtimeNamespaces.entries()) {
|
||||
expireNamespace(namespaceKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,13 +157,25 @@ export function createMemoryCache(options = {}) {
|
||||
runtimeNamespaces.clear();
|
||||
totalRuntimeEvents = 0;
|
||||
},
|
||||
prune(predicate) {
|
||||
if (typeof predicate !== 'function') return;
|
||||
for (const key of store.keys()) {
|
||||
if (predicate(key)) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
setRuntimeEvent(scope, event, ttlMs = MINUTE_MS) {
|
||||
const now = Date.now();
|
||||
removeExpired(now);
|
||||
const namespaceKey = getNamespaceKey(scope);
|
||||
|
||||
expireNamespace(namespaceKey, now);
|
||||
if (totalRuntimeEvents >= maxTotalEvents) {
|
||||
expireAllNamespaces(now);
|
||||
}
|
||||
|
||||
const safeTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : MINUTE_MS;
|
||||
const expiresAt = now + safeTtlMs;
|
||||
const namespaceKey = getNamespaceKey(scope);
|
||||
const bucket = runtimeNamespaces.get(namespaceKey) ?? { events: [] };
|
||||
|
||||
bucket.events.push({
|
||||
@@ -182,9 +199,9 @@ export function createMemoryCache(options = {}) {
|
||||
},
|
||||
getRecentRuntimeEvents(scope, params = {}) {
|
||||
const now = Date.now();
|
||||
removeExpired(now);
|
||||
|
||||
const namespaceKey = getNamespaceKey(scope);
|
||||
expireNamespace(namespaceKey, now);
|
||||
|
||||
const bucket = runtimeNamespaces.get(namespaceKey);
|
||||
if (!bucket) return [];
|
||||
|
||||
|
||||
+108
-29
@@ -38,9 +38,50 @@ function loadMemoryStore() {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
let currentSavePromise = null;
|
||||
let pendingSavePromise = null;
|
||||
let pendingSaveResolve = null;
|
||||
class CoalescingWorker {
|
||||
constructor(workerFn) {
|
||||
this.workerFn = workerFn;
|
||||
this.currentPromise = null;
|
||||
this.pendingPromise = null;
|
||||
this.pendingResolve = null;
|
||||
this.pendingArg = null;
|
||||
}
|
||||
|
||||
enqueue(arg) {
|
||||
this.pendingArg = arg;
|
||||
|
||||
if (this.pendingPromise) {
|
||||
return this.pendingPromise;
|
||||
}
|
||||
|
||||
if (this.currentPromise) {
|
||||
this.pendingPromise = new Promise((resolve) => {
|
||||
this.pendingResolve = resolve;
|
||||
});
|
||||
return this.pendingPromise;
|
||||
}
|
||||
|
||||
this.currentPromise = this._execute(arg);
|
||||
return this.currentPromise;
|
||||
}
|
||||
|
||||
async _execute(arg) {
|
||||
try {
|
||||
await this.workerFn(arg);
|
||||
} finally {
|
||||
this.currentPromise = null;
|
||||
if (this.pendingPromise) {
|
||||
const resolve = this.pendingResolve;
|
||||
const nextArg = this.pendingArg;
|
||||
this.pendingPromise = null;
|
||||
this.pendingResolve = null;
|
||||
this.pendingArg = null;
|
||||
this.currentPromise = this._execute(nextArg).then(resolve);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastSaveTime = 0;
|
||||
const MIN_SAVE_INTERVAL_MS = 1000;
|
||||
|
||||
@@ -61,31 +102,13 @@ async function performSave(store) {
|
||||
lastSaveTime = Date.now();
|
||||
} catch (err) {
|
||||
console.error('Failed to save memory store:', err);
|
||||
} finally {
|
||||
currentSavePromise = null;
|
||||
if (pendingSavePromise) {
|
||||
const resolve = pendingSaveResolve;
|
||||
pendingSavePromise = null;
|
||||
pendingSaveResolve = null;
|
||||
currentSavePromise = performSave(store).then(() => resolve());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveWorker = new CoalescingWorker(performSave);
|
||||
|
||||
async function saveMemoryStore(store) {
|
||||
if (pendingSavePromise) {
|
||||
return pendingSavePromise;
|
||||
}
|
||||
|
||||
if (currentSavePromise) {
|
||||
pendingSavePromise = new Promise((resolve) => {
|
||||
pendingSaveResolve = resolve;
|
||||
});
|
||||
return pendingSavePromise;
|
||||
}
|
||||
|
||||
currentSavePromise = performSave(store);
|
||||
return currentSavePromise;
|
||||
return saveWorker.enqueue(store);
|
||||
}
|
||||
|
||||
const memoryStore = loadMemoryStore();
|
||||
@@ -227,6 +250,24 @@ function recordThroughput(samples, now) {
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateAgentCache(agentId) {
|
||||
if (!agentId) return;
|
||||
// If the cache supports prune (it should), use it to remove entries for this agent.
|
||||
if (typeof cache.prune === 'function') {
|
||||
cache.prune((key) => {
|
||||
try {
|
||||
const parsed = JSON.parse(key);
|
||||
return parsed.agent_id === agentId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback if cache implementation doesn't support prune
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests agent events and stores them as memory records.
|
||||
*
|
||||
@@ -255,7 +296,22 @@ export async function ingestEvents(events, options = {}) {
|
||||
}
|
||||
},
|
||||
});
|
||||
cache.clear();
|
||||
|
||||
const uniqueAgents = new Set();
|
||||
if (options.agent_id) uniqueAgents.add(options.agent_id);
|
||||
for (const event of events) {
|
||||
if (event.agent_id) uniqueAgents.add(event.agent_id);
|
||||
}
|
||||
for (const record of records) {
|
||||
if (record.agent_id) uniqueAgents.add(record.agent_id);
|
||||
}
|
||||
|
||||
if (uniqueAgents.size > 0) {
|
||||
for (const agentId of uniqueAgents) invalidateAgentCache(agentId);
|
||||
} else {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
@@ -372,7 +428,16 @@ export async function runPruneCycle(options = {}) {
|
||||
});
|
||||
emitMetric(options, 'memory_pruned_total', { count: prunable.length, retention_ms: retentionMs });
|
||||
|
||||
cache.clear();
|
||||
const uniqueAgents = new Set();
|
||||
for (const memory of prunable) {
|
||||
if (memory.agent_id) uniqueAgents.add(memory.agent_id);
|
||||
}
|
||||
|
||||
if (uniqueAgents.size > 0) {
|
||||
for (const agentId of uniqueAgents) invalidateAgentCache(agentId);
|
||||
} else if (prunable.length > 0) {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
const result = { pruned: prunable };
|
||||
if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) {
|
||||
@@ -397,7 +462,11 @@ export async function runPruneCycle(options = {}) {
|
||||
export async function pinMemory(id, options = {}) {
|
||||
const repository = options.repository ?? memoryRepository;
|
||||
const updated = await repository.setPinned(id, true);
|
||||
cache.clear();
|
||||
if (updated && updated.agent_id) {
|
||||
invalidateAgentCache(updated.agent_id);
|
||||
} else {
|
||||
cache.clear();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -414,7 +483,11 @@ export async function pinMemory(id, options = {}) {
|
||||
export async function unpinMemory(id, options = {}) {
|
||||
const repository = options.repository ?? memoryRepository;
|
||||
const updated = await repository.setPinned(id, false);
|
||||
cache.clear();
|
||||
if (updated && updated.agent_id) {
|
||||
invalidateAgentCache(updated.agent_id);
|
||||
} else {
|
||||
cache.clear();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -431,8 +504,14 @@ export async function unpinMemory(id, options = {}) {
|
||||
*/
|
||||
export async function markMemoryMerged(id, mergedInto, options = {}) {
|
||||
const repository = options.repository ?? memoryRepository;
|
||||
const memory = await inspectMemory(id, { repository });
|
||||
const merged = await repository.markMerged(id, mergedInto);
|
||||
cache.clear();
|
||||
|
||||
if (memory && memory.agent_id) {
|
||||
invalidateAgentCache(memory.agent_id);
|
||||
} else {
|
||||
cache.clear();
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
agent: content-audit-agent
|
||||
cadence: daily
|
||||
session: 2026-02-21T18-44-11Z
|
||||
run-start: 2026-02-21T18-44-11Z
|
||||
prompt: src/prompts/daily/content-audit-agent.md
|
||||
---
|
||||
# Test Log — Content Audit — 2026-02-21
|
||||
|
||||
## `npm run validate:docs`
|
||||
**Result:** PASS
|
||||
**Output:**
|
||||
```
|
||||
Found 365 markdown files. Checking links (including anchors)...
|
||||
No broken links found.
|
||||
```
|
||||
|
||||
## Manual Verification
|
||||
**Command:** `node bin/torch-lock.mjs --help`
|
||||
**Result:** Originally missing commands; verified fix (pending verification step).
|
||||
@@ -5,10 +5,10 @@ Run Start: 2026-02-20T01-13-24Z
|
||||
Prompt: src/prompts/daily/deps-security-agent.md
|
||||
|
||||
## Pending Tasks
|
||||
- [ ] Investigate `eslint` upgrade path to resolve `minimatch` (High) vulnerability.
|
||||
- [ ] Investigate `ajv` vulnerability in `eslint` dependency tree.
|
||||
|
||||
## Completed Tasks
|
||||
- [x] Investigate `eslint` upgrade path to resolve `minimatch` (High) vulnerability.
|
||||
- [x] Investigate `ajv` vulnerability in `eslint` dependency tree.
|
||||
- [x] Run npm audit.
|
||||
- [x] Run npm outdated.
|
||||
- [x] Generate security report.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
agent: content-audit-agent
|
||||
cadence: daily
|
||||
session: 2026-02-21T18-44-11Z
|
||||
run-start: 2026-02-21T18-44-11Z
|
||||
prompt: src/prompts/daily/content-audit-agent.md
|
||||
---
|
||||
# Todo Note — Content Audit — 2026-02-21
|
||||
|
||||
## Completed
|
||||
- [x] Run `npm run validate:docs`.
|
||||
- [x] Check `README.md` commands against implementation.
|
||||
- [x] Update `src/constants.mjs` to include missing commands in usage text.
|
||||
|
||||
## Pending
|
||||
- None.
|
||||
Reference in new issue
Block a user