mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-09 14:22:13 +00:00
Merge branch 'main' into unit-tests-lib-mjs-1840636568505209047
This commit is contained in:
27 files changed
+1124
-82
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
loadTorchConfig,
|
||||
getRelays,
|
||||
getNamespace,
|
||||
} from './torch-config.mjs';
|
||||
import { getRoster } from './roster.mjs';
|
||||
import { queryLocks } from './lock-ops.mjs';
|
||||
import { todayDateStr } from './utils.mjs';
|
||||
|
||||
export async function cmdCheck(cadence) {
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const dateStr = todayDateStr();
|
||||
const config = loadTorchConfig();
|
||||
const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || [];
|
||||
|
||||
console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`);
|
||||
console.error(`Relays: ${relays.join(', ')}`);
|
||||
if (pausedAgents.length > 0) {
|
||||
console.error(`Paused agents: ${pausedAgents.join(', ')}`);
|
||||
}
|
||||
|
||||
const locks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))];
|
||||
const roster = getRoster(cadence);
|
||||
|
||||
const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])];
|
||||
const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent));
|
||||
const available = roster.filter((a) => !excludedAgents.includes(a));
|
||||
|
||||
const result = {
|
||||
namespace,
|
||||
cadence,
|
||||
date: dateStr,
|
||||
locked: lockedAgents.sort(),
|
||||
paused: pausedAgents.sort(),
|
||||
excluded: excludedAgents.sort(),
|
||||
available: available.sort(),
|
||||
lockCount: locks.length,
|
||||
unknownLockedAgents: unknownLockedAgents.sort(),
|
||||
locks: locks.map((l) => ({
|
||||
agent: l.agent,
|
||||
eventId: l.eventId,
|
||||
createdAt: l.createdAtIso,
|
||||
expiresAt: l.expiresAtIso,
|
||||
platform: l.platform,
|
||||
})),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
getRelays,
|
||||
getNamespace,
|
||||
} from './torch-config.mjs';
|
||||
import { getRoster } from './roster.mjs';
|
||||
import { queryLocks } from './lock-ops.mjs';
|
||||
import { todayDateStr, nowUnix } from './utils.mjs';
|
||||
|
||||
export async function cmdList(cadence) {
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const dateStr = todayDateStr();
|
||||
const cadences = cadence ? [cadence] : ['daily', 'weekly'];
|
||||
|
||||
console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`);
|
||||
|
||||
const results = await Promise.all(
|
||||
cadences.map(async (c) => {
|
||||
const locks = await queryLocks(relays, c, dateStr, namespace);
|
||||
return { c, locks };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { c, locks } of results) {
|
||||
console.log(`\n${'='.repeat(72)}`);
|
||||
console.log(`Active ${namespace} ${c} locks (${dateStr})`);
|
||||
console.log('='.repeat(72));
|
||||
|
||||
if (locks.length === 0) {
|
||||
console.log(' (no active locks)');
|
||||
continue;
|
||||
}
|
||||
|
||||
const sorted = locks.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const lock of sorted) {
|
||||
const age = nowUnix() - lock.createdAt;
|
||||
const ageMin = Math.round(age / 60);
|
||||
const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null;
|
||||
const remainMin = remaining ? Math.round(remaining / 60) : '?';
|
||||
|
||||
console.log(
|
||||
` ${(lock.agent ?? 'unknown').padEnd(30)} ` +
|
||||
`age: ${String(ageMin).padStart(4)}m ` +
|
||||
`ttl: ${String(remainMin).padStart(4)}m ` +
|
||||
`platform: ${lock.platform ?? '?'} ` +
|
||||
`event: ${lock.eventId?.slice(0, 12)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
const roster = getRoster(c);
|
||||
const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
|
||||
const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent));
|
||||
const available = roster.filter((a) => !lockedAgents.has(a));
|
||||
|
||||
if (unknownLockedAgents.length > 0) {
|
||||
console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`);
|
||||
console.log(` Available: ${available.join(', ') || '(none)'}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
|
||||
import {
|
||||
getRelays,
|
||||
getNamespace,
|
||||
getTtl,
|
||||
} from './torch-config.mjs';
|
||||
import {
|
||||
RACE_CHECK_DELAY_MS,
|
||||
} from './constants.mjs';
|
||||
import { getRoster } from './roster.mjs';
|
||||
import { queryLocks, publishLock } from './lock-ops.mjs';
|
||||
import { todayDateStr, nowUnix } from './utils.mjs';
|
||||
import { ExitError } from './errors.mjs';
|
||||
|
||||
export async function cmdLock(agent, cadence, dryRun = false) {
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const dateStr = todayDateStr();
|
||||
const ttl = getTtl();
|
||||
const now = nowUnix();
|
||||
const expiresAt = now + ttl;
|
||||
|
||||
console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`);
|
||||
console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`);
|
||||
console.error(`Relays: ${relays.join(', ')}`);
|
||||
|
||||
const roster = getRoster(cadence);
|
||||
if (!roster.includes(agent)) {
|
||||
console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
|
||||
console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
|
||||
throw new ExitError(1, 'Agent not in roster');
|
||||
}
|
||||
|
||||
console.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];
|
||||
console.error(
|
||||
`LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` +
|
||||
`(created ${earliest.createdAtIso}, platform: ${earliest.platform})`,
|
||||
);
|
||||
console.log('LOCK_STATUS=denied');
|
||||
console.log('LOCK_REASON=already_locked');
|
||||
console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`);
|
||||
throw new ExitError(3, 'Lock denied');
|
||||
}
|
||||
|
||||
console.error('Step 2: Generating ephemeral keypair...');
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`);
|
||||
|
||||
console.error('Step 3: Building lock event...');
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 30078,
|
||||
created_at: now,
|
||||
tags: [
|
||||
['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`],
|
||||
['t', `${namespace}-agent-lock`],
|
||||
['t', `${namespace}-lock-${cadence}`],
|
||||
['t', `${namespace}-lock-${cadence}-${dateStr}`],
|
||||
['expiration', String(expiresAt)],
|
||||
],
|
||||
content: JSON.stringify({
|
||||
agent,
|
||||
cadence,
|
||||
status: 'started',
|
||||
namespace,
|
||||
date: dateStr,
|
||||
platform: process.env.AGENT_PLATFORM || 'unknown',
|
||||
lockedAt: new Date(now * 1000).toISOString(),
|
||||
expiresAt: new Date(expiresAt * 1000).toISOString(),
|
||||
}),
|
||||
},
|
||||
sk,
|
||||
);
|
||||
|
||||
console.error(` Event ID: ${event.id}`);
|
||||
|
||||
if (dryRun) {
|
||||
console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent');
|
||||
console.error('RACE CHECK: won (dry run — no real contention possible)');
|
||||
} else {
|
||||
console.error('Step 4: Publishing to relays...');
|
||||
await publishLock(relays, event);
|
||||
|
||||
console.error('Step 5: Race check...');
|
||||
await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS));
|
||||
|
||||
const postLocks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const racingLocks = postLocks
|
||||
.filter((l) => l.agent === agent)
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
|
||||
if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) {
|
||||
const winner = racingLocks[0];
|
||||
console.error(
|
||||
`RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`,
|
||||
);
|
||||
console.log('LOCK_STATUS=race_lost');
|
||||
console.log('LOCK_REASON=earlier_claim_exists');
|
||||
console.log(`LOCK_WINNER_EVENT=${winner.eventId}`);
|
||||
throw new ExitError(3, 'Race check lost');
|
||||
}
|
||||
|
||||
console.error('RACE CHECK: won');
|
||||
}
|
||||
|
||||
console.log('LOCK_STATUS=ok');
|
||||
console.log(`LOCK_EVENT_ID=${event.id}`);
|
||||
console.log(`LOCK_PUBKEY=${pk}`);
|
||||
console.log(`LOCK_AGENT=${agent}`);
|
||||
console.log(`LOCK_CADENCE=${cadence}`);
|
||||
console.log(`LOCK_DATE=${dateStr}`);
|
||||
console.log(`LOCK_EXPIRES=${expiresAt}`);
|
||||
console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`);
|
||||
return { status: 'ok', eventId: event.id };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Context for Decomposition of src/lib.mjs
|
||||
|
||||
## File Selection
|
||||
- **File**: `src/lib.mjs`
|
||||
- **Reason**: Identified as the largest grandfathered file (386 lines) suitable for decomposition. `landing/index.html` and `dashboard/index.html` were larger but are not JS source files.
|
||||
- **Node Version**: v22.22.0
|
||||
- **Extraction Plan**:
|
||||
- Extract `cmdCheck` to `src/cmd-check.mjs`
|
||||
- Extract `cmdLock` to `src/cmd-lock.mjs`
|
||||
- Extract `cmdList` to `src/cmd-list.mjs`
|
||||
- Extract `ExitError` to `src/errors.mjs`
|
||||
- Extract utilities to `src/utils.mjs`
|
||||
|
||||
## Goal
|
||||
Reduce `src/lib.mjs` size by > 200 lines while preserving behavior.
|
||||
+26
-9
@@ -9,7 +9,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) {
|
||||
// this file is in <root>/src/dashboard.mjs, so '..' goes to <root>
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// URL parsing
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
let pathname = url.pathname;
|
||||
@@ -25,10 +25,13 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) {
|
||||
// Priority: User's CWD > Package default
|
||||
if (pathname === '/torch-config.json') {
|
||||
const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
|
||||
if (fs.existsSync(userConfigPath)) {
|
||||
try {
|
||||
await fs.promises.access(userConfigPath, fs.constants.F_OK);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
fs.createReadStream(userConfigPath).pipe(res);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
// If not found in CWD, fall through to serve from packageRoot (if it exists there)
|
||||
// or return empty object if missing?
|
||||
@@ -39,13 +42,27 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) {
|
||||
const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), '');
|
||||
let filePath = path.join(packageRoot, safePath);
|
||||
|
||||
// If directory, try index.html
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
// Check if file exists and is a file
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
try {
|
||||
let stats = await fs.promises.stat(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
const indexPath = path.join(filePath, 'index.html');
|
||||
try {
|
||||
const indexStats = await fs.promises.stat(indexPath);
|
||||
if (indexStats.isFile()) {
|
||||
filePath = indexPath;
|
||||
stats = indexStats;
|
||||
} else {
|
||||
// Found directory but index.html is not a file
|
||||
throw new Error('Not found');
|
||||
}
|
||||
} catch {
|
||||
// Found directory but index.html does not exist
|
||||
throw new Error('Not found');
|
||||
}
|
||||
} else if (!stats.isFile()) {
|
||||
throw new Error('Not a file');
|
||||
}
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Decisions for src/lib.mjs Decomposition
|
||||
|
||||
## Module Boundaries
|
||||
- `cmdCheck`, `cmdLock`, and `cmdList` are distinct commands with their own dependencies and logic. They were extracted to separate files to improve modularity and reduce `lib.mjs` size.
|
||||
- `ExitError` is used across multiple modules, so it was extracted to a shared `src/errors.mjs`.
|
||||
- `todayDateStr` and `nowUnix` are generic utilities used across commands, extracted to `src/utils.mjs`.
|
||||
|
||||
## Re-exports
|
||||
- `src/lib.mjs` re-exports the extracted commands (`cmdCheck`, `cmdLock`, `cmdList`) to maintain backward compatibility for any consumers using it as a library.
|
||||
|
||||
## File Size
|
||||
- `src/lib.mjs` was reduced significantly by moving the command implementations out.
|
||||
@@ -0,0 +1,7 @@
|
||||
// Custom Error class for controlled exits
|
||||
export class ExitError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
+251
-11
@@ -14,16 +14,80 @@ import {
|
||||
} from './torch-config.mjs';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_PORT,
|
||||
RACE_CHECK_DELAY_MS,
|
||||
VALID_CADENCES,
|
||||
} from './constants.mjs';
|
||||
import { cmdInit, cmdUpdate } from './ops.mjs';
|
||||
import { getRoster as _getRoster } from './roster.mjs';
|
||||
import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs';
|
||||
import { cmdDashboard } from './dashboard.mjs';
|
||||
import { cmdCheck } from './cmd-check.mjs';
|
||||
import { cmdLock } from './cmd-lock.mjs';
|
||||
import { cmdList } from './cmd-list.mjs';
|
||||
import { ExitError } from './errors.mjs';
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const DEFAULT_RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
];
|
||||
|
||||
const DEFAULT_TTL = 7200;
|
||||
const DEFAULT_NAMESPACE = 'torch';
|
||||
const DEFAULT_QUERY_TIMEOUT_MS = 15_000;
|
||||
const VALID_CADENCES = new Set(['daily', 'weekly']);
|
||||
|
||||
// If installed as a package, prompts/roster.json is relative to this file
|
||||
const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json');
|
||||
const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json');
|
||||
|
||||
const FALLBACK_ROSTER = {
|
||||
daily: [
|
||||
'audit-agent',
|
||||
'ci-health-agent',
|
||||
'const-refactor-agent',
|
||||
'content-audit-agent',
|
||||
'decompose-agent',
|
||||
'deps-security-agent',
|
||||
'design-system-audit-agent',
|
||||
'docs-agent',
|
||||
'docs-alignment-agent',
|
||||
'docs-code-investigator',
|
||||
'innerhtml-migration-agent',
|
||||
'known-issues-agent',
|
||||
'load-test-agent',
|
||||
'onboarding-audit-agent',
|
||||
'perf-agent',
|
||||
'prompt-curator-agent',
|
||||
'protocol-research-agent',
|
||||
'scheduler-update-agent',
|
||||
'style-agent',
|
||||
'test-audit-agent',
|
||||
'todo-triage-agent',
|
||||
],
|
||||
weekly: [
|
||||
'bug-reproducer-agent',
|
||||
'changelog-agent',
|
||||
'dead-code-agent',
|
||||
'event-schema-agent',
|
||||
'frontend-console-debug-agent',
|
||||
'fuzz-agent',
|
||||
'interop-agent',
|
||||
'perf-deepdive-agent',
|
||||
'perf-optimization-agent',
|
||||
'pr-review-agent',
|
||||
'race-condition-agent',
|
||||
'refactor-agent',
|
||||
'smoke-agent',
|
||||
'telemetry-agent',
|
||||
'test-coverage-agent',
|
||||
'weekly-synthesis-agent',
|
||||
],
|
||||
};
|
||||
|
||||
let cachedCanonicalRoster = null;
|
||||
|
||||
// Custom Error class for controlled exits
|
||||
class ExitError extends Error {
|
||||
constructor(code, message) {
|
||||
@@ -32,6 +96,107 @@ class ExitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getRelays() {
|
||||
const config = loadTorchConfig();
|
||||
const envRelays = process.env.NOSTR_LOCK_RELAYS;
|
||||
if (envRelays) {
|
||||
return envRelays.split(',').map((r) => r.trim()).filter(Boolean);
|
||||
}
|
||||
if (config.nostrLock.relays?.length) {
|
||||
return config.nostrLock.relays;
|
||||
}
|
||||
return DEFAULT_RELAYS;
|
||||
}
|
||||
|
||||
function getNamespace() {
|
||||
const config = loadTorchConfig();
|
||||
const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim();
|
||||
return namespace || DEFAULT_NAMESPACE;
|
||||
}
|
||||
|
||||
function getTtl() {
|
||||
const config = loadTorchConfig();
|
||||
const envTtl = process.env.NOSTR_LOCK_TTL;
|
||||
if (envTtl) {
|
||||
const parsed = parseInt(envTtl, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
if (config.nostrLock.ttlSeconds) {
|
||||
return config.nostrLock.ttlSeconds;
|
||||
}
|
||||
return DEFAULT_TTL;
|
||||
}
|
||||
|
||||
function getQueryTimeoutMs() {
|
||||
const config = loadTorchConfig();
|
||||
const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS;
|
||||
if (envValue) {
|
||||
const parsed = parseInt(envValue, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
|
||||
function loadCanonicalRoster() {
|
||||
if (cachedCanonicalRoster) return cachedCanonicalRoster;
|
||||
|
||||
let rosterPath = ROSTER_FILE;
|
||||
|
||||
// Prefer user-managed roster if present
|
||||
if (fs.existsSync(USER_ROSTER_FILE)) {
|
||||
rosterPath = USER_ROSTER_FILE;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(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 (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 (err) {
|
||||
console.debug(`Note: Could not load roster from ${rosterPath}: ${err.message}`);
|
||||
// It's okay if roster file is missing when used as a library/CLI without the file present
|
||||
}
|
||||
|
||||
cachedCanonicalRoster = FALLBACK_ROSTER;
|
||||
return cachedCanonicalRoster;
|
||||
}
|
||||
|
||||
function parseEnvRoster(value) {
|
||||
if (!value) return null;
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getRoster(cadence) {
|
||||
const config = loadTorchConfig();
|
||||
const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER);
|
||||
const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER);
|
||||
const canonical = loadCanonicalRoster();
|
||||
const dailyFromConfig = config.nostrLock.dailyRoster;
|
||||
const weeklyFromConfig = config.nostrLock.weeklyRoster;
|
||||
|
||||
if (cadence === 'daily') {
|
||||
if (dailyFromEnv && dailyFromEnv.length) return dailyFromEnv;
|
||||
if (dailyFromConfig && dailyFromConfig.length) return dailyFromConfig;
|
||||
return canonical.daily;
|
||||
}
|
||||
|
||||
if (weeklyFromEnv && weeklyFromEnv.length) return weeklyFromEnv;
|
||||
if (weeklyFromConfig && weeklyFromConfig.length) return weeklyFromConfig;
|
||||
return canonical.weekly;
|
||||
}
|
||||
|
||||
function todayDateStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -71,10 +236,12 @@ export async function cmdCheck(cadence, deps = {}) {
|
||||
const locks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))];
|
||||
const roster = getRoster(cadence);
|
||||
const rosterSet = new Set(roster);
|
||||
|
||||
const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])];
|
||||
const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent));
|
||||
const available = roster.filter((a) => !excludedAgents.includes(a));
|
||||
const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents]);
|
||||
const excludedAgents = [...excludedAgentsSet];
|
||||
const unknownLockedAgents = lockedAgents.filter((agent) => !rosterSet.has(agent));
|
||||
const available = roster.filter((a) => !excludedAgentsSet.has(a));
|
||||
|
||||
const result = {
|
||||
namespace,
|
||||
@@ -241,12 +408,8 @@ export async function cmdList(cadence, deps = {}) {
|
||||
|
||||
error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`);
|
||||
|
||||
const results = await Promise.all(
|
||||
cadences.map(async (c) => {
|
||||
const locks = await queryLocks(relays, c, dateStr, namespace);
|
||||
return { c, locks };
|
||||
}),
|
||||
);
|
||||
for (const c of cadences) {
|
||||
const locks = await queryLocks(relays, c, dateStr, namespace);
|
||||
|
||||
for (const { c, locks } of results) {
|
||||
log(`\n${'='.repeat(72)}`);
|
||||
@@ -275,8 +438,9 @@ export async function cmdList(cadence, deps = {}) {
|
||||
}
|
||||
|
||||
const roster = getRoster(c);
|
||||
const rosterSet = new Set(roster);
|
||||
const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
|
||||
const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent));
|
||||
const unknownLockedAgents = [...lockedAgents].filter((agent) => !rosterSet.has(agent));
|
||||
const available = roster.filter((a) => !lockedAgents.has(a));
|
||||
|
||||
if (unknownLockedAgents.length > 0) {
|
||||
@@ -288,6 +452,82 @@ export async function cmdList(cadence, deps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) {
|
||||
// Resolve package root relative to this file (src/lib.mjs)
|
||||
// this file is in <root>/src/lib.mjs, so '..' goes to <root>
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// URL parsing
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
let pathname = url.pathname;
|
||||
|
||||
// Redirect / to /dashboard/
|
||||
if (pathname === '/' || pathname === '/dashboard') {
|
||||
res.writeHead(302, { 'Location': '/dashboard/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: /torch-config.json
|
||||
// Priority: User's CWD > Package default
|
||||
if (pathname === '/torch-config.json') {
|
||||
const userConfigPath = path.resolve(process.cwd(), 'torch-config.json');
|
||||
if (fs.existsSync(userConfigPath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
fs.createReadStream(userConfigPath).pipe(res);
|
||||
return;
|
||||
}
|
||||
// If not found in CWD, fall through to serve from packageRoot (if it exists there)
|
||||
// or return empty object if missing?
|
||||
// Falling through means it looks for packageRoot/torch-config.json
|
||||
}
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), '');
|
||||
let filePath = path.join(packageRoot, safePath);
|
||||
|
||||
// If directory, try index.html
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(filePath, 'index.html');
|
||||
}
|
||||
|
||||
// Check if file exists and is a file
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
// MIME types
|
||||
const extname = path.extname(filePath);
|
||||
let contentType = 'text/plain';
|
||||
switch (extname) {
|
||||
case '.html': contentType = 'text/html'; break;
|
||||
case '.js': contentType = 'text/javascript'; break;
|
||||
case '.css': contentType = 'text/css'; break;
|
||||
case '.json': contentType = 'application/json'; break;
|
||||
case '.png': contentType = 'image/png'; break;
|
||||
case '.jpg': contentType = 'image/jpeg'; break;
|
||||
case '.svg': contentType = 'image/svg+xml'; break;
|
||||
case '.ico': contentType = 'image/x-icon'; break;
|
||||
case '.md': contentType = 'text/markdown'; break;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`Dashboard running at http://localhost:${port}/dashboard/`);
|
||||
console.log(`Serving files from ${packageRoot}`);
|
||||
console.log(`Using configuration from ${process.cwd()}`);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { command: null, agent: null, cadence: null, dryRun: false, force: false, port: DEFAULT_DASHBOARD_PORT };
|
||||
let i = 0;
|
||||
|
||||
+8
-3
@@ -46,19 +46,24 @@ export async function queryLocks(relays, cadence, dateStr, namespace) {
|
||||
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
|
||||
const queryTimeoutMs = getQueryTimeoutMs();
|
||||
|
||||
let timeoutHandle;
|
||||
|
||||
try {
|
||||
const events = await Promise.race([
|
||||
pool.querySync(relays, {
|
||||
kinds: [30078],
|
||||
'#t': [tagFilter],
|
||||
}),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs),
|
||||
),
|
||||
new Promise((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
|
||||
return filterActiveLocks(events.map(parseLockEvent));
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
pool.close(relays);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -29,9 +29,7 @@ function getPaths(userRoot) {
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function transformContent(content) {
|
||||
@@ -54,7 +52,7 @@ function copyFile(src, dest, transform = false, overwrite = true) {
|
||||
return true; // Copied/Overwritten
|
||||
}
|
||||
|
||||
export async function cmdInit(force = false, cwd = process.cwd()) {
|
||||
export function cmdInit(force = false, cwd = process.cwd()) {
|
||||
const paths = getPaths(cwd);
|
||||
console.log(`Initializing torch configuration in ${paths.torchDir}...`);
|
||||
|
||||
@@ -103,7 +101,7 @@ export async function cmdInit(force = false, cwd = process.cwd()) {
|
||||
console.log('You can now customize the files in torch/ and torch/prompts/.');
|
||||
}
|
||||
|
||||
export async function cmdUpdate(force = false, cwd = process.cwd()) {
|
||||
export function cmdUpdate(force = false, cwd = process.cwd()) {
|
||||
const paths = getPaths(cwd);
|
||||
console.log(`Updating torch configuration in ${paths.torchDir}...`);
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Test Log
|
||||
|
||||
## Lint
|
||||
- Ran `npm run lint`.
|
||||
- Result: Passed (with existing warnings).
|
||||
|
||||
## Manual Verification
|
||||
- Plan to run `node bin/torch-lock.mjs --help` to verify CLI entry point works.
|
||||
- Plan to run `npm test` to verify `torch-config` is still working.
|
||||
|
||||
## File Size Check
|
||||
- Baseline will be updated after verification.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Decomposition TODO
|
||||
|
||||
- [x] Create `src/errors.mjs` with `ExitError`.
|
||||
- [x] Create `src/utils.mjs` with `todayDateStr`, `nowUnix`.
|
||||
- [x] Create `src/cmd-check.mjs`.
|
||||
- [x] Create `src/cmd-lock.mjs`.
|
||||
- [x] Create `src/cmd-list.mjs`.
|
||||
- [x] Update `src/lib.mjs` to import new modules.
|
||||
- [ ] Verify with lint.
|
||||
- [ ] Verify with tests.
|
||||
- [ ] Update file size baseline.
|
||||
@@ -84,15 +84,15 @@ export function _resetTorchConfigCache() {
|
||||
cachedConfig = null;
|
||||
}
|
||||
|
||||
export function loadTorchConfig() {
|
||||
export function loadTorchConfig(fileSystem = fs) {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
const configPath = getTorchConfigPath();
|
||||
let raw = {};
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
if (fileSystem.existsSync(configPath)) {
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
raw = JSON.parse(fileSystem.readFileSync(configPath, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function todayDateStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function nowUnix() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
Reference in new issue
Block a user