diff --git a/src/lib.mjs b/src/lib.mjs index d78c164..3d0b66e 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -129,6 +129,15 @@ export async function cmdLock(agent, cadence, dryRun = false, deps = {}) { 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})`, @@ -251,13 +260,19 @@ export async function cmdList(cadence, deps = {}) { 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) : '?'; + + let remainMin = '?'; + if (lock.status === 'completed') { + remainMin = 'done'; + } else if (lock.expiresAt) { + const remaining = lock.expiresAt - nowUnix(); + remainMin = Math.round(remaining / 60); + } log( ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + `age: ${String(ageMin).padStart(4)}m ` + - `ttl: ${String(remainMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)} ` + `platform: ${lock.platform ?? '?'} ` + `event: ${lock.eventId?.slice(0, 12)}...`, ); @@ -278,6 +293,95 @@ export async function cmdList(cadence, deps = {}) { } } +export async function cmdComplete(agent, cadence, dryRun = false, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + queryLocks = _queryLocks, + publishLock = _publishLock, + generateSecretKey = _generateSecretKey, + getPublicKey = _getPublicKey, + finalizeEvent = _finalizeEvent, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = getRelays(); + const namespace = getNamespace(); + const dateStr = getDateStr(); + const now = nowUnix(); + + error(`Completing task: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + error(`Relays: ${relays.join(', ')}`); + + // 1. Find existing lock + const locks = await queryLocks(relays, cadence, dateStr, namespace); + const myLock = locks.find((l) => l.agent === agent); + + if (!myLock) { + error(`ERROR: No active lock found for agent "${agent}" on ${dateStr}.`); + error(`Cannot complete a task that is not locked or has already expired.`); + throw new ExitError(1, 'No active lock found'); + } + + if (myLock.status === 'completed') { + error(`Task is already marked as completed (event ${myLock.eventId}).`); + log('LOCK_STATUS=completed'); + return { status: 'completed', eventId: myLock.eventId }; + } + + // 2. Build completion event + const startedAtIso = myLock.createdAtIso; + + error('Step 1: Generating completion event...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', `${namespace}-agent-lock`], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + // No expiration tag -> permanent + ], + content: JSON.stringify({ + agent, + cadence, + status: 'completed', + namespace, + date: dateStr, + platform: process.env.AGENT_PLATFORM || 'unknown', + startedAt: startedAtIso, + completedAt: new Date(now * 1000).toISOString(), + }), + }, + sk, + ); + + error(` Event ID: ${event.id}`); + + if (dryRun) { + error('Step 2: [DRY RUN] Skipping publish — event built but not sent'); + } else { + error('Step 2: Publishing completion event...'); + await publishLock(relays, event); + error(' Published successfully.'); + } + + log('LOCK_STATUS=completed'); + log(`LOCK_EVENT_ID=${event.id}`); + log(`LOCK_PUBKEY=${pk}`); + log(`LOCK_AGENT=${agent}`); + log(`LOCK_CADENCE=${cadence}`); + log(`LOCK_DATE=${dateStr}`); + + return { status: 'completed', eventId: event.id }; +} function parseArgs(argv) { const args = { command: null, agent: null, cadence: null, dryRun: false, force: false, port: DEFAULT_DASHBOARD_PORT }; @@ -314,12 +418,13 @@ function usage() { console.error(`Usage: torch-lock [options] Commands: - check --cadence Check locked agents (JSON) - lock --agent --cadence Claim a lock - list [--cadence ] Print active lock table + check --cadence Check locked agents (JSON) + lock --agent --cadence Claim a lock + complete --agent --cadence Mark task as completed (permanent) + list [--cadence ] Print active lock table dashboard [--port ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT}) - init [--force] Initialize torch/ directory in current project - update [--force] Update torch/ configuration (backups, merges) + init [--force] Initialize torch/ directory in current project + update [--force] Update torch/ configuration (backups, merges) Options: --dry-run Build and sign the event but do not publish @@ -374,6 +479,19 @@ export async function main(argv) { break; } + case 'complete': { + if (!args.agent) { + console.error('ERROR: --agent is required for complete'); + throw new ExitError(1, 'Missing agent'); + } + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for complete`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdComplete(args.agent, args.cadence, args.dryRun); + break; + } + case 'list': { if (args.cadence && !VALID_CADENCES.has(args.cadence)) { console.error(`ERROR: --cadence must be one of: ${[...VALID_CADENCES].join(', ')}`); diff --git a/test/lib.test.mjs b/test/lib.test.mjs index de8dab5..e965baf 100644 --- a/test/lib.test.mjs +++ b/test/lib.test.mjs @@ -1,6 +1,6 @@ import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert'; -import { cmdCheck, cmdLock, cmdList } from '../src/lib.mjs'; +import { cmdCheck, cmdLock, cmdList, cmdComplete } from '../src/lib.mjs'; describe('src/lib.mjs', () => { // Common mocks @@ -148,6 +148,23 @@ describe('src/lib.mjs', () => { assert.match(logs.join('\n'), /LOCK_STATUS=denied/); }); + it('fails if agent is already completed', async () => { + const existingLocks = [ + { agent: 'agent1', eventId: 'done-id', status: 'completed' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => existingLocks, + }; + + await assert.rejects( + async () => await cmdLock('agent1', 'daily', false, deps), + (err) => err.code === 3 && err.message === 'Task already completed' + ); + assert.match(logs.join('\n'), /LOCK_STATUS=denied/); + assert.match(logs.join('\n'), /LOCK_REASON=already_completed/); + }); + it('fails if race check is lost', async () => { let callCount = 0; const deps = { @@ -165,12 +182,6 @@ describe('src/lib.mjs', () => { agent: 'agent1', eventId: 'mock-id', createdAt: 200, // our timestamp (mocked event) - // We need to ensure cmdLock sees this as later. - // In cmdLock, `now` is mocked via verify? No, `now` is `nowUnix()`. - // But we can't easily mock `nowUnix` inside `cmdLock` without injecting it too. - // However, `cmdLock` compares `createdAt`. - // The mocked event has `created_at: now`. - // If we return a lock with smaller `createdAt`, we lose. }]; }, publishLock: async () => {}, @@ -180,14 +191,6 @@ describe('src/lib.mjs', () => { raceCheckDelayMs: 1, }; - // We need to make sure the "winner" has explicit createdAt that is definitely before what cmdLock generates. - // cmdLock uses Math.floor(Date.now() / 1000). - // We can't mock Date.now easily unless we inject it. - // But we can inject `finalizeEvent` to control the created timestamp of our event? - // No, `finalizeEvent` receives the template. - // Let's rely on the fact that `cmdLock` uses `nowUnix()`. - // The "winner" in `queryLocks` return can have `createdAt: 0`. - await assert.rejects( async () => await cmdLock('agent1', 'daily', false, deps), (err) => err.code === 3 && err.message === 'Race check lost' @@ -259,4 +262,67 @@ describe('src/lib.mjs', () => { assert.match(output, /\(no active locks\)/); }); }); + + describe('cmdComplete', () => { + const mockPublishLock = async () => {}; + const mockGenerateSecretKey = () => new Uint8Array(32); + const mockGetPublicKey = () => 'mock-pubkey'; + const mockFinalizeEvent = (t) => ({ ...t, id: 'mock-id-complete' }); + + it('successfully completes an active lock', async () => { + let publishedEvent = null; + const locks = [ + { agent: 'agent1', eventId: 'lock-id', createdAt: 100, createdAtIso: 'iso1', status: 'started' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => locks, + publishLock: async (relays, evt) => { publishedEvent = evt; }, + generateSecretKey: mockGenerateSecretKey, + getPublicKey: mockGetPublicKey, + finalizeEvent: mockFinalizeEvent, + }; + + const result = await cmdComplete('agent1', 'daily', false, deps); + + assert.strictEqual(result.status, 'completed'); + assert.strictEqual(result.eventId, 'mock-id-complete'); + assert.ok(publishedEvent); + // Verify no expiration tag + const expTag = publishedEvent.tags.find(t => t[0] === 'expiration'); + assert.strictEqual(expTag, undefined); + // Verify content + const content = JSON.parse(publishedEvent.content); + assert.strictEqual(content.status, 'completed'); + assert.strictEqual(content.startedAt, 'iso1'); + assert.match(logs.join('\n'), /LOCK_STATUS=completed/); + }); + + it('fails if no active lock found', async () => { + const deps = { + ...baseDeps, + queryLocks: async () => [], // No locks + }; + + await assert.rejects( + async () => await cmdComplete('agent1', 'daily', false, deps), + (err) => err.code === 1 && err.message === 'No active lock found' + ); + }); + + it('detects already completed task', async () => { + const locks = [ + { agent: 'agent1', eventId: 'done-id', status: 'completed' } + ]; + const deps = { + ...baseDeps, + queryLocks: async () => locks, + }; + + const result = await cmdComplete('agent1', 'daily', false, deps); + assert.strictEqual(result.status, 'completed'); + assert.strictEqual(result.eventId, 'done-id'); + assert.match(logs.join('\n'), /LOCK_STATUS=completed/); + }); + }); });