mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
I have implemented 'torch-lock complete' for persistent task history. This involved adding cmdComplete to src/lib.mjs to handle permanent completion events, updating cmdLock to prevent locking completed tasks, modifying cmdList to show 'done' status, exposing the command in the CLI, and adding the necessary unit tests.
Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
419189d237
commit
7a8bab4b1b
2 files changed
+207
-23
No files matched your search
+126
-8
@@ -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 <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
|
||||
@@ -374,6 +479,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
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user