Files
safetynet/apps/browser/library-organization.cjs

47 lines
3.8 KiB
JavaScript

'use strict';
// Fixed native operations only. A model never supplies URLs, scripts or handles.
function createLibraryOrganization({capture,assertCurrent,propose,commit,applyTabs,inspect,audit,changed=()=>{},autoAllowed=()=>false}={}){
if([capture,assertCurrent,propose,commit,applyTabs,inspect,audit].some(fn=>typeof fn!=='function'))throw TypeError('Invalid library callbacks');
let busy=false;const proposals=new Map();
const checkInput=input=>{if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['proposalId','confirm','autoApply'].includes(k))||typeof input.proposalId!=='string'||input.proposalId.length>128)throw Error('Invalid organization request');};
async function plan({signal}={}){
if(busy)throw Error('Library organization is already running');busy=true;changed();
try{
const current=snapshot=>{if(signal?.aborted)throw Error('Organization action cancelled');assertCurrent(snapshot);};
const snapshot=await capture();current(snapshot);await inspect(snapshot);current(snapshot);
const result=await propose(snapshot);current(snapshot);
if(typeof result?.proposalId!=='string'||result.proposalId.length>128)throw Error('Invalid library proposal');
if(snapshot.snapshotHash&&(result.binding?.snapshotHash!==snapshot.snapshotHash||result.binding?.policyVersion!==snapshot.policyVersion||result.binding?.bookmarkRevision!==snapshot.bookmarkRevision))throw Error('Invalid organization proposal binding');
const core=await import('../../packages/core/library.mjs');
const validated=core.validateLibraryPlan(result.plan,{bookmarks:snapshot.bookmarks,tabs:snapshot.tabs});
// Keep authoritative plan and binding in native memory, never renderer input.
proposals.clear();proposals.set(result.proposalId,{snapshot,plan:validated,expires:Date.now()+300000});
await audit('library.ai.proposed',`bookmarks-${validated.bookmarks.length}:tabs-${validated.tabs.length}`);
return {...result,plan:structuredClone(validated)};
}finally{busy=false;changed();}
}
async function apply(input,{signal}={}){
checkInput(input);if(busy)throw Error('Library organization is already running');
const stored=proposals.get(input.proposalId);
if(!stored||stored.expires<=Date.now())throw Error('Organization proposal expired. Generate a fresh proposal.');
if(input.confirm!==true&&!(input.autoApply===true&&autoAllowed()))throw Error('Review and confirm the organization proposal');
if(signal?.aborted)throw Error('Organization action cancelled');assertCurrent(stored.snapshot);busy=true;changed();
let committed=false;
try{
const result=await commit(input.proposalId,stored.snapshot,input.autoApply===true);committed=true;
// Bookmarks are committed centrally. Navigation or policy changes can occur
// meanwhile: never silently apply stale tab assignments after such a change.
if(signal?.aborted)throw Error('Organization action cancelled');assertCurrent(stored.snapshot,{afterCommit:true,result});
applyTabs(stored.plan.tabs,result);proposals.delete(input.proposalId);
await audit('library.ai.applied',`bookmarks-${stored.plan.bookmarks.length}:tabs-${stored.plan.tabs.length}`);
return {completed:true,bookmarkRevision:result.revision,tabCount:stored.plan.tabs.length};
}catch(error){
// eslint-disable-next-line preserve-caught-error -- Expose only a fixed sanitized error; the original cause may contain sensitive paths or data.
if(committed){proposals.delete(input.proposalId);await audit('library.ai.reconcile','central-bookmarks-committed');throw Error('Bookmarks were organized centrally; tabs were unchanged because the browser context changed. Refresh the library.');}
throw error;
}finally{busy=false;changed();}
}
return {plan,apply,isBusy:()=>busy};
}
module.exports={createLibraryOrganization};