mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-29 08:51:53 +00:00
Merge pull request #84 from PR0M3TH3AN/torch-lock-completion-6824629536982484145
feat: add torch-lock complete command for persistent history
This commit is contained in:
2 files changed
+207
-23
No files matched your search
+126
-8
@@ -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
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user