diff --git a/src/lib.mjs b/src/lib.mjs index d78c164..81b770f 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -23,13 +23,51 @@ 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 { ExitError } from './errors.mjs'; -import { todayDateStr, nowUnix } from './utils.mjs'; +import { todayDateStr, nowUnix, getIsoWeekStr } from './utils.mjs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; useWebSocketImplementation(WebSocket); // Re-export for backward compatibility/library usage export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock }; +async function getCompletedAgents(cadence, logDir, deps) { + const { readdir = fs.readdir, getDateStr = todayDateStr, getIsoWeek = getIsoWeekStr } = deps; + const completed = new Set(); + const targetDir = path.join(logDir, cadence); + + try { + const files = await readdir(targetDir); + const today = getDateStr(); + const currentWeek = getIsoWeek(); + + for (const file of files) { + // Format: YYYY-MM-DDTHH-mm-ssZ____.md + const match = file.match(/^(\d{4}-\d{2}-\d{2})T.*__([a-zA-Z0-9-_]+)__(.*)\.md$/); + if (!match) continue; + + const [_, datePart, agent, status] = match; + + if (status === 'completed') { + if (cadence === 'daily' && datePart === today) { + completed.add(agent); + } else if (cadence === 'weekly') { + const fileWeek = getIsoWeek(datePart); // datePart is YYYY-MM-DD + if (fileWeek === currentWeek) { + completed.add(agent); + } + } + } + } + } catch (err) { + if (err.code !== 'ENOENT') { + console.error(`Warning: Failed to read log dir ${targetDir}: ${err.message}`); + } + } + + return completed; +} export async function cmdCheck(cadence, deps = {}) { const { @@ -40,7 +78,9 @@ export async function cmdCheck(cadence, deps = {}) { getRoster = _getRoster, getDateStr = todayDateStr, log = console.log, - error = console.error + error = console.error, + logDir = 'task-logs', + ignoreLogs = false, } = deps; const relays = getRelays(); @@ -55,12 +95,20 @@ export async function cmdCheck(cadence, deps = {}) { error(`Paused agents: ${pausedAgents.join(', ')}`); } + let completedAgents = new Set(); + if (!ignoreLogs) { + completedAgents = await getCompletedAgents(cadence, logDir, deps); + if (completedAgents.size > 0) { + error(`Completed agents (logs): ${[...completedAgents].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 rosterSet = new Set(roster); - const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents]); + const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents, ...completedAgents]); const excludedAgents = [...excludedAgentsSet]; const unknownLockedAgents = lockedAgents.filter((agent) => !rosterSet.has(agent)); const available = roster.filter((a) => !excludedAgentsSet.has(a)); @@ -71,6 +119,7 @@ export async function cmdCheck(cadence, deps = {}) { date: dateStr, locked: lockedAgents.sort(), paused: pausedAgents.sort(), + completed: [...completedAgents].sort(), excluded: excludedAgents.sort(), available: available.sort(), lockCount: locks.length, @@ -280,7 +329,16 @@ export async function cmdList(cadence, deps = {}) { function parseArgs(argv) { - const args = { command: null, agent: null, cadence: null, dryRun: false, force: false, port: DEFAULT_DASHBOARD_PORT }; + const args = { + command: null, + agent: null, + cadence: null, + dryRun: false, + force: false, + port: DEFAULT_DASHBOARD_PORT, + logDir: 'task-logs', + ignoreLogs: false, + }; let i = 0; if (argv.length > 0 && !argv[0].startsWith('-')) { @@ -304,6 +362,10 @@ function parseArgs(argv) { args.force = true; } else if (arg === '--port') { args.port = parseInt(argv[++i], 10) || DEFAULT_DASHBOARD_PORT; + } else if (arg === '--log-dir') { + args.logDir = argv[++i]; + } else if (arg === '--ignore-logs') { + args.ignoreLogs = true; } } @@ -322,8 +384,10 @@ Commands: update [--force] Update torch/ configuration (backups, merges) Options: - --dry-run Build and sign the event but do not publish - --force Overwrite existing files (for init) or all files (for update) + --dry-run Build and sign the event but do not publish + --force Overwrite existing files (for init) or all files (for update) + --log-dir Path to task logs directory (default: task-logs) + --ignore-logs Skip checking local logs for completed tasks Environment: NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch) @@ -357,7 +421,7 @@ export async function main(argv) { console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for check`); throw new ExitError(1, 'Missing cadence'); } - await cmdCheck(args.cadence); + await cmdCheck(args.cadence, { logDir: args.logDir, ignoreLogs: args.ignoreLogs }); break; } diff --git a/src/utils.mjs b/src/utils.mjs index f1a5f15..0538a3e 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -5,3 +5,25 @@ export function todayDateStr() { export function nowUnix() { return Math.floor(Date.now() / 1000); } + +export function getIsoWeekStr(dateInput) { + // Parse input to ensure we work with a Date object + // If dateInput is YYYY-MM-DD string, new Date() treats it as UTC. + const d = dateInput ? new Date(dateInput) : new Date(); + + // If valid date, use UTC components to avoid local timezone issues + if (isNaN(d.getTime())) return ''; + + // Copy date so we don't mutate the original if passed by reference (though new Date() handles that) + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + + // ISO week date system: Thursday determines the year + // day: 0 (Sun) -> 6 (Sat). We want 1 (Mon) -> 7 (Sun) + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil((((date - yearStart) / 86400000) + 1) / 7); + + return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; +} diff --git a/test/lib.test.mjs b/test/lib.test.mjs index de8dab5..ab0723c 100644 --- a/test/lib.test.mjs +++ b/test/lib.test.mjs @@ -25,6 +25,7 @@ describe('src/lib.mjs', () => { getDateStr: () => mockDateStr, log: mockLog, error: mockError, + readdir: async () => [], }; beforeEach(() => { diff --git a/test/scheduler-ratchet.test.mjs b/test/scheduler-ratchet.test.mjs new file mode 100644 index 0000000..e981f4b --- /dev/null +++ b/test/scheduler-ratchet.test.mjs @@ -0,0 +1,122 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { cmdCheck } from '../src/lib.mjs'; + +describe('Scheduler Ratchet Logic (Log Checking)', () => { + const mockRelays = ['wss://relay.test']; + const mockNamespace = 'test-ns'; + const mockTtl = 3600; + const mockRoster = ['agentA', 'agentB', 'agentC']; + const mockDateStr = '2023-10-27'; // A Friday + const mockIsoWeekStr = '2023-W43'; + + // Base dependencies for cmdCheck + const baseDeps = { + getRelays: () => mockRelays, + getNamespace: () => mockNamespace, + getTtl: () => mockTtl, + getRoster: () => mockRoster, + loadTorchConfig: () => ({ scheduler: { paused: { daily: [], weekly: [] } } }), + getDateStr: () => mockDateStr, + getIsoWeek: () => mockIsoWeekStr, + queryLocks: async () => [], // No active locks by default + log: () => {}, + error: () => {}, + // Default readdir mock: returns empty array + readdir: async () => [], + logDir: 'mock-logs', + }; + + it('excludes agents completed today (daily)', async () => { + const deps = { + ...baseDeps, + readdir: async () => [ + '2023-10-27T10-00-00Z__agentA__completed.md', // Matching date + '2023-10-26T10-00-00Z__agentB__completed.md', // Old date + '2023-10-27T12-00-00Z__agentC__failed.md', // Failed today + ], + }; + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.completed, ['agentA']); + assert.ok(result.excluded.includes('agentA')); + assert.ok(!result.excluded.includes('agentB')); // Completed yesterday, not excluded today + assert.ok(!result.excluded.includes('agentC')); // Failed, not excluded + assert.deepStrictEqual(result.available, ['agentB', 'agentC']); + }); + + it('excludes agents completed this week (weekly)', async () => { + const deps = { + ...baseDeps, + getIsoWeek: (date) => { + if (!date) return mockIsoWeekStr; + // Mock simple week logic: 2023-10-23 to 2023-10-29 is W43 + if (date >= '2023-10-23' && date <= '2023-10-29') return '2023-W43'; + return '2023-W42'; + }, + readdir: async () => [ + '2023-10-23T10-00-00Z__agentA__completed.md', // Monday (in week) + '2023-10-20T10-00-00Z__agentB__completed.md', // Last week + ], + }; + + const result = await cmdCheck('weekly', deps); + + assert.deepStrictEqual(result.completed, ['agentA']); + assert.ok(result.excluded.includes('agentA')); + assert.ok(!result.excluded.includes('agentB')); + assert.deepStrictEqual(result.available, ['agentB', 'agentC']); + }); + + it('combines locked and completed agents in exclusion list', async () => { + const deps = { + ...baseDeps, + queryLocks: async () => [{ agent: 'agentB', eventId: 'ev1' }], // agentB is locked + readdir: async () => [ + '2023-10-27T10-00-00Z__agentA__completed.md', // agentA is completed + ], + }; + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.completed, ['agentA']); + assert.deepStrictEqual(result.locked, ['agentB']); + + // agentA (completed) and agentB (locked) should be excluded + assert.ok(result.excluded.includes('agentA')); + assert.ok(result.excluded.includes('agentB')); + assert.deepStrictEqual(result.available, ['agentC']); + }); + + it('ignores logs when --ignore-logs is set', async () => { + const deps = { + ...baseDeps, + readdir: async () => [ + '2023-10-27T10-00-00Z__agentA__completed.md', + ], + ignoreLogs: true, + }; + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.completed, []); + assert.ok(!result.excluded.includes('agentA')); + assert.deepStrictEqual(result.available, ['agentA', 'agentB', 'agentC']); + }); + + it('handles missing log directory gracefully', async () => { + const deps = { + ...baseDeps, + readdir: async () => { + const err = new Error('ENOENT'); + err.code = 'ENOENT'; + throw err; + }, + }; + + const result = await cmdCheck('daily', deps); + assert.deepStrictEqual(result.completed, []); + assert.deepStrictEqual(result.available, ['agentA', 'agentB', 'agentC']); + }); +}); diff --git a/test/utils.test.mjs b/test/utils.test.mjs new file mode 100644 index 0000000..06b2b94 --- /dev/null +++ b/test/utils.test.mjs @@ -0,0 +1,36 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { getIsoWeekStr } from '../src/utils.mjs'; + +describe('Date Utilities', () => { + describe('getIsoWeekStr', () => { + it('returns correct ISO week for simple cases', () => { + assert.strictEqual(getIsoWeekStr('2023-01-02'), '2023-W01'); // Mon + assert.strictEqual(getIsoWeekStr('2023-01-08'), '2023-W01'); // Sun + assert.strictEqual(getIsoWeekStr('2023-01-09'), '2023-W02'); // Mon + }); + + it('handles year boundaries correctly', () => { + // 2023-01-01 is Sunday. Belong to last week of 2022. + assert.strictEqual(getIsoWeekStr('2023-01-01'), '2022-W52'); + // 2024-01-01 is Monday. Week 1 of 2024. + assert.strictEqual(getIsoWeekStr('2024-01-01'), '2024-W01'); + // 2023-12-31 is Sunday. Week 52 of 2023. + assert.strictEqual(getIsoWeekStr('2023-12-31'), '2023-W52'); + }); + + it('handles leap years', () => { + assert.strictEqual(getIsoWeekStr('2024-02-29'), '2024-W09'); + }); + + it('handles empty input (defaults to today)', () => { + // Just check it returns a string in correct format + const result = getIsoWeekStr(); + assert.match(result, /^\d{4}-W\d{2}$/); + }); + + it('handles invalid input gracefully', () => { + assert.strictEqual(getIsoWeekStr('invalid-date'), ''); + }); + }); +});