Merge pull request #84 from PR0M3TH3AN/torch-lock-completion-6824629536982484145

feat: add torch-lock complete command for persistent history
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-14 13:52:44 -05:00
commit cfe7e9ec8e
2 files changed
+207 -23

No files matched your search

+126 -8
View File
@@ -178,6 +178,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})`,
@@ -300,13 +309,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)}...`,
);
@@ -327,6 +342,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 = {
@@ -376,12 +480,13 @@ function usage() {
console.error(`Usage: torch-lock <command> [options]
Commands:
check --cadence <daily|weekly> Check locked agents (JSON)
lock --agent <name> --cadence <daily|weekly> Claim a lock
list [--cadence <daily|weekly>] Print active lock table
check --cadence <daily|weekly> Check locked agents (JSON)
lock --agent <name> --cadence <daily|weekly> Claim a lock
complete --agent <name> --cadence <daily|weekly> Mark task as completed (permanent)
list [--cadence <daily|weekly>] Print active lock table
dashboard [--port <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
@@ -438,6 +543,19 @@ export async function main(argv) {
break;
}
case 'complete': {
if (!args.agent) {
console.error('ERROR: --agent <name> 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(', ')}`);
+81 -15
View File
@@ -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
@@ -149,6 +149,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 = {
@@ -166,12 +183,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 () => {},
@@ -181,14 +192,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'
@@ -260,4 +263,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/);
});
});
});