Ctrl K
ComunidadGuías…

yh-action-journal.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

// Per-user, per-device local journal for ONLY explicitly tagged guarded actions.
// A reservation is fsynced BEFORE any native input / CDP side effect. After a
// crash, running or corrupted records fail closed and cannot auto-replay.
const VALID=/^a_[0-9a-f]{64}$/;
const sha=value=>crypto.createHash('sha256').update(value).digest('hex');
export class FileActionJournal {
  constructor(directory){
    if(typeof directory!=='string'||!path.isAbsolute(directory))throw Error('Absolute journal directory required');
    this.directory=directory;
    fs.mkdirSync(directory,{recursive:true,mode:0o700});
  }
  location(id){
    if(typeof id!=='string'||!VALID.test(id))throw Error('INVALID_ACTION_ID');
    return path.join(this.directory,sha(id)+'.json');
  }
  read(location){
    try{
      const b=fs.readFileSync(location);
      if(!b.length||b.length>131072)throw Error('Malformed journal record');
      const r=JSON.parse(b.toString('utf8'));
      if(!r||typeof r!=='object'||!['running','completed'].includes(r.state)||
         !/^[0-9a-f]{64}$/.test(r.fingerprint))throw Error('Malformed journal record');
      return r;
    }catch(error){
      if(error?.code==='ENOENT')return null;
      throw Error('OUTCOME_UNCERTAIN: local action journal unreadable; inspect device; never auto-replay');
    }
  }
  async execute(id,payload,perform){
    if(typeof perform!=='function')throw Error('Action handler required');
    const location=this.location(id);
    const raw=JSON.stringify(payload);
    if(typeof raw!=='string'||Buffer.byteLength(raw)>131072)throw Error('INVALID_REQUEST: action too large');
    const fingerprint=sha(raw);
    let cached=this.read(location);
    if(!cached){
      let file;
      try{
        file=fs.openSync(location,'wx',0o600);
        const reservation=JSON.stringify({version:1,idHash:sha(id),fingerprint,state:'running',
          createdAt:new Date().toISOString()});
        fs.writeSync(file,reservation);fs.fsyncSync(file);
      }catch(error){
        if(error?.code==='EEXIST')cached=this.read(location);
        else throw Error('OUTCOME_UNCERTAIN: unable to reserve action on disk; input not attempted');
      }finally{if(file!==undefined)fs.closeSync(file);}
    }
    if(cached){
      if(cached.idHash!==sha(id)||cached.fingerprint!==fingerprint)
        throw Error('INVALID_REQUEST: action ID reused with different arguments');
      if(cached.state==='completed'&&Object.hasOwn(cached,'result'))return cached.result;
      throw Error('OUTCOME_UNCERTAIN: local action already reserved; inspect status before another action');
    }
    // No automatic replay even if the process dies immediately after reservation.
    const result=await perform();
    const record=JSON.stringify({version:1,idHash:sha(id),fingerprint,state:'completed',
      result,completedAt:new Date().toISOString()});
    if(Buffer.byteLength(record)>131072)
      throw Error('OUTCOME_UNCERTAIN: result too large to journal; action may already have executed');
    const temp=location+'.'+crypto.randomUUID()+'.tmp';
    try{
      const f=fs.openSync(temp,'wx',0o600);
      try{fs.writeSync(f,record);fs.fsyncSync(f);}finally{fs.closeSync(f);}
      fs.renameSync(temp,location);
      return result;
    }catch{
      throw Error('OUTCOME_UNCERTAIN: action dispatched but durable result unavailable; never auto-replay');
    }finally{try{fs.rmSync(temp,{force:true});}catch{}}
  }
  status(id){
    const r=this.read(this.location(id));
    return r?{state:r.state,result:r.state==='completed'?r.result:null}:
      {state:'not_found',result:null};
  }
}