NanoJev-Web / runtime /browser.mjs
candypunk's picture
Release runtime 1.1 with named workflows and hybrid by default
d786697 verified
Raw History Blame Contribute Delete
29.3 kB
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {readFile,writeFile,mkdir} from 'node:fs/promises';
import {resolve,dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {createHash} from 'node:crypto';
import {Workflow, workflowSuccess, validateTarget} from './workflow.mjs';
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export function purposeFor(c) { return c.purpose === 'reveal' || !c.purpose ? INPUTS.has(c.op) ? 'input' : 'interaction' : c.purpose; }
const exec=promisify(execFile);
const AB=process.env.NANOJEV_AGENT_BROWSER_BIN??resolve(dirname(fileURLToPath(import.meta.url)),'../node_modules/.bin/agent-browser');
const ROOT=resolve(dirname(fileURLToPath(import.meta.url)),'..');
export const INSTRUCTION='Choose one allowed next browser action. Complete assigned inputs and interactions before advancing or submitting. When the current stage has no unfinished fields or interactions, choose its assigned advance action, then submit on the final review. The unfinished-input indicator does not describe availability of advance/submit actions. Use scroll to reveal an offscreen target. Do not change satisfied fields or use optional reset/cancel actions. While busy wait. DONE requires visible success. If blocked and no permitted action can help choose BLOCKED.';
export const SPECIALS={DONE:'Task is successfully saved; finish.',WAIT:'Page is busy; wait for a new observation.',BLOCKED:'Required progress is unavailable; stop for host assistance.'};
const INPUTS=new Set(['fill','type','clear','select','multiselect','check','uncheck','radio','upload']);
const OPS=new Set([...INPUTS,'click','hover','dblclick','drag','focus','press','open','option','add','remove','link','tab']);
const hash=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
export function description(a){return `operation=${a.op}; purpose=${a.purpose}; current=${a.current}; expected=${a.expected}; viewport=${a.viewport}; enabled=${a.enabled}; blocked=${a.blocked}; target purpose=${a.sourcePurpose??a.purpose}.`;}
export function stateText(s){return `Task: complete the assigned browser form and confirm successful saving.\nPage busy=${s.busy}; success=${s.success}; incomplete fields=${Boolean(s.fields)}; incomplete interactions=${Boolean(s.interactions)}; blocking dialog=${s.modal}; unfinished input/interaction in viewport=${Boolean(s.ready)}.`;}
export class Browser{
constructor(session,{origins}={}){this.session=session;this.calls=0;this.ms=0;this.targetId=null;this.parentTargets=[];this.flags=['--session',session,'--pin-tab','--headed','false','--idle-timeout','0'];if(origins?.length)this.flags.push('--allowed-domains',[...new Set(origins.map(x=>new URL(x).hostname))].join(','));}
async command(args){const start=performance.now();this.calls++;const {stdout}=await exec(AB,[...this.flags,'--json',...args],{timeout:20000,maxBuffer:8*1024*1024,encoding:'utf8'});this.ms+=performance.now()-start;const result=JSON.parse(stdout);if(!result.success)throw Error(`agent-browser: ${String(result.error).slice(0,200)}`);return result.data;}
async observe(task){
const tabs=(await this.command(['tab'])).tabs.filter(x=>x.active);if(tabs.length!==1)throw Error('Ambiguous active tab');
if(this.targetId&&this.targetId!==tabs[0].targetId)throw Error('Active tab changed unexpectedly');this.targetId=tabs[0].targetId;
// One read-only DOM request replaces the full accessibility snapshot in the hot loop.
// The separate tab check preserves the owned-session/active-target invariant.
const expression=`(async()=>{const read=${readDom.toString()};const task=${JSON.stringify(task)};const start=performance.now();let dom=read(task);for(;;){await new Promise(r=>setTimeout(r,25));const next=read(task);const stable=!next.animating&&JSON.stringify(next.geometry)===JSON.stringify(dom.geometry);dom=next;if(stable||performance.now()-start>=1200){dom.stability={settled:stable,waitedMs:performance.now()-start};dom.busy||=!stable;return JSON.stringify(dom);}}})()`;
const raw=await this.command(['eval',expression]);const dom=typeof raw.result==='string'?JSON.parse(raw.result):raw.result;
if(!task.allowedOrigins.includes(dom.origin))throw Error('Origin outside authorized task');
const actions=[];let fields=0,interactions=0;
for(const c of dom.controls){
if(c.count>1)throw Error(`Ambiguous selector for ${c.label}`);
if(!c.visible)continue;
if(c.type==='password')throw Error('Secret input requires host helper');
const purpose=purposeFor(c);
if(!c.blocked&&c.expected!=='optional'&&c.current!=='match'){
if(purpose==='input')fields++;
if(purpose==='interaction')interactions++;
}
// Disabled and modal-covered controls cannot be authorized executable actions.
if(c.blocked||!c.enabled)continue;
const role=c.role||'button';
const a={...c,role,purpose,expected:c.expected??'assigned'};
actions.push(a);
if(c.viewport!=='inside')actions.push({...a,op:'scroll',role:'viewport',purpose:'reveal',sourcePurpose:purpose});
}
const ready=actions.filter(a=>a.op!=='scroll'&&a.current!=='match'&&a.expected==='assigned'&&a.viewport==='inside'&&['input','interaction','dismiss'].includes(a.purpose)).length;
const s={busy:dom.busy,success:dom.success,fields,interactions,modal:dom.modal,ready};
const relevant={s,actions:actions.map(a=>({...a,value:undefined,condition:undefined,expect:undefined,when:undefined})),targetId:this.targetId};
return {state:stateText(s),s,actions,dom,signature:hash(relevant)};
}
async act(a){
if(!a.enabled||a.blocked)throw Error('Model selected a disabled or covered action');
if(a.op!=='scroll'&&a.viewport!=='inside')throw Error('Model attempted an offscreen action without scrolling');
if(a.href&&['click','link'].includes(a.op)&&!this.allowedOrigins.includes(new URL(a.href).origin))throw Error('Link destination outside task origins');
if(a.frame){
if(a.op==='scroll')await this.command(['scrollintoview',a.frame]);
await this.command(['frame',a.frame]);
try{return await this.act({...a,frame:null});}finally{await this.command(['frame','main']);}
}
const sel=a.selector;
switch(a.op){
case 'fill':
if(['date','time','datetime-local'].includes(a.type))return this.fillTemporal(a);
return this.command(['fill',sel,String(a.value)]);
case 'type':
await this.command(['focus',sel]);
await this.command(['press','End']);
return this.command(['keyboard','type',String(a.value)]);
case 'clear':return this.command(['fill',sel,'']);
case 'select':return this.command(['select',sel,String(a.value)]);
case 'multiselect':return this.command(['select',sel,...a.value.map(String)]);
case 'check':case 'uncheck':case 'hover':case 'dblclick':case 'focus':return this.command([a.op,sel]);
case 'scroll':return this.command(['scrollintoview',sel]);
case 'press':return process.platform==='darwin'&&/^(Escape|Esc)$/i.test(a.key)?this.pressEscape():this.command(['press',a.key]);
case 'drag':return this.command(['drag',sel,a.destination]);
case 'upload':return this.command(['upload',sel,...(Array.isArray(a.value)?a.value:[a.value]).map(String)]);
case 'tab':{
if(a.mode==='open'){
if(!this.allowedOrigins.includes(new URL(a.url).origin))throw Error('Tab URL outside task origins');
this.parentTargets.push(this.targetId);await this.command(['tab','new',a.url]);
}else if(a.mode==='return'){
const parent=this.parentTargets.pop();if(!parent)throw Error('No authorized parent tab');await this.command(['tab',parent]);
}else throw Error('Unsupported tab operation');
const tabs=(await this.command(['tab'])).tabs.filter(t=>t.active);if(tabs.length!==1)throw Error('Tab activation ambiguous');this.targetId=tabs[0].targetId;return;
}
case 'radio':case 'click':case 'open':case 'option':case 'add':case 'remove':case 'link':return this.command(['click',sel]);
default:throw Error(`Unsupported runtime operation ${a.op}`);
}
}
async pressEscape(){
// agent-browser 0.37.1 CLI press on this Mac emits a stream of unrelated
// native Minus key events after Escape. Its documented input_keyboard
// channel sends the intended native key once without that translation.
const status=await this.command(['stream','status']);
if(!status.enabled||!Number.isInteger(status.port)||status.port<1||status.port>65535)throw Error('agent-browser keyboard stream unavailable');
const ws=new WebSocket(`ws://127.0.0.1:${status.port}/?pacing=ack&maxFps=1`);
try{
await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(Error('Keyboard stream connection timed out')),5000);ws.onopen=()=>{clearTimeout(timer);resolve();};ws.onerror=()=>{clearTimeout(timer);reject(Error('Keyboard stream connection failed'));};});
for(const eventType of ['keyDown','keyUp'])ws.send(JSON.stringify({type:'input_keyboard',eventType,key:'Escape',code:'Escape',windowsVirtualKeyCode:27,modifiers:0}));
this.streamKeyboardEvents=(this.streamKeyboardEvents??0)+2;
await this.command(['wait','100']);
}finally{ws.close();}
}
async fillTemporal(a){
const raw=String(a.value),parts=[];
if(a.type!=='time'){
const m=raw.match(/^(\d{4})-(\d{2})-(\d{2})/);if(!m)throw Error('Date must use ISO format');
parts.push([/День|\bDay\b/i,m[3]],[/Месяц|\bMonth\b/i,m[2]],[/Год|\bYear\b/i,m[1]]);
}
if(a.type!=='date'){
const m=raw.match(/(?:T|^)(\d{2}):(\d{2})(?::(\d{2}))?$/);if(!m)throw Error('Time must use HH:MM[:SS]');
parts.push([/Час|\bHour\b/i,m[1]],[/Минут|\bMinute\b/i,m[2]]);if(m[3])parts.push([/Секунд|\bSecond\b/i,m[3]]);
}
for(const [pattern,value] of parts){
const s=await this.command(['snapshot','-s',a.selector]);
const found=Object.entries(s.refs).filter(([r,m])=>m.role==='spinbutton'&&pattern.test(m.name)&&new RegExp('\\bref='+r+'\\b').test(s.snapshot));
if(found.length!==1)throw Error('Date/time segment is missing or ambiguous in current locale');
await this.command(['click','@'+found[0][0]]);
for(const digit of value)await this.command(['press',digit]);
}
await this.command(['press','Tab']);
const result=await this.command(['get','value',a.selector]);if(result.value!==raw)throw Error('Date/time input did not preserve the assigned value');return result;
}
}
export function readDom(task){
if(!task.allowedOrigins.includes(location.origin))throw Error('Origin outside authorized task');
const context=frame=>frame?document.querySelector(frame)?.contentDocument:document;
const query=(s,frame)=>s?Array.from(context(frame)?.querySelectorAll(s)??[]):[];
const shown=e=>!!e&&!!e.getClientRects().length&&getComputedStyle(e).visibility!=='hidden'&&getComputedStyle(e).display!=='none';
const normalize=s=>String(s??'').normalize('NFKC').replace(/\s+/g,' ').trim().toLowerCase();
const role=e=>e.getAttribute('role')||(e.tagName==='SELECT'?(e.multiple?'listbox':'combobox'):e.tagName==='TEXTAREA'?'textbox':e.tagName==='INPUT'?({number:'spinbutton',checkbox:'checkbox',radio:'radio',file:'button',button:'button',submit:'button'}[e.type]??'textbox'):e.tagName==='A'?'link':'button');
const labelText=e=>Array.from(e.childNodes).map(n=>n.nodeType===3?n.textContent:n.nodeType===1&&!n.matches('input,textarea,select,button,script,style')?labelText(n):'').join(' ');
const names=e=>{
const labelled=(e.getAttribute('aria-labelledby')??'').split(/\s+/).filter(Boolean).map(id=>e.ownerDocument.getElementById(id)?.textContent??'').join(' ');
const labels=Array.from(e.labels??[]).map(labelText);
const accessible=labelled||e.getAttribute('aria-label');
return [accessible,...labels,e.getAttribute('placeholder'),...(['BUTTON','A'].includes(e.tagName)||e.getAttribute('role')? [e.textContent]:[]),...(['button','submit'].includes(e.type)?[e.value]:[])].filter(Boolean).map(normalize);
};
const css=e=>{
if(e.id&&e.ownerDocument.querySelectorAll('#'+CSS.escape(e.id)).length===1)return '#'+CSS.escape(e.id);
const parts=[];for(let n=e;n&&n.nodeType===1;n=n.parentElement){const tag=n.tagName.toLowerCase(),siblings=n.parentElement?Array.from(n.parentElement.children).filter(x=>x.tagName===n.tagName):[n];parts.unshift(tag+':nth-of-type('+(siblings.indexOf(n)+1)+')');}return parts.join(' > ');
};
const locate=(target,op,frame)=>{
const t=typeof target==='string'?{name:target}:target,doc=context(frame);if(!doc)return [];
let scope=doc;
if(t.within){const scopes=query(t.within,frame).filter(shown);if(scopes.length!==1)throw Error('Target scope is missing or ambiguous');scope=scopes[0];}
else {const dialogs=Array.from(doc.querySelectorAll('dialog[open],[role="dialog"][aria-modal="true"]')).filter(shown);if(dialogs.length)scope=dialogs.at(-1);}
const selector=t.selector??(['fill','type','clear'].includes(op)?'input,textarea,[contenteditable="true"]':op==='select'||op==='multiselect'?'select':op==='check'||op==='uncheck'||op==='radio'?'input,[role="checkbox"],[role="radio"]':'button,a,input[type="button"],input[type="submit"],[role="button"],[role="link"],[role="tab"],[role="option"]');
return Array.from(scope.querySelectorAll(selector)).filter(shown).filter(e=>(!t.role||role(e)===t.role)&&(!t.name||names(e).includes(normalize(t.name))));
};
const value=e=>!e?null:e.type==='checkbox'||e.type==='radio'?e.checked:e.tagName==='SELECT'&&e.multiple?Array.from(e.selectedOptions).map(o=>o.value):e.type==='file'?Array.from(e.files).map(f=>({name:f.name,size:f.size})):('value'in e?e.value:e.textContent.trim());
const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
const viewBounds=el=>{
const w=el.ownerDocument.defaultView,b={top:0,left:0,right:w.innerWidth,bottom:w.innerHeight};
for(let p=el.parentElement;p;p=p.parentElement){
const style=w.getComputedStyle(p),r=p.getBoundingClientRect();
if(/auto|scroll|hidden|clip/.test(style.overflowY)){b.top=Math.max(b.top,r.top+p.clientTop);b.bottom=Math.min(b.bottom,r.top+p.clientTop+p.clientHeight);}
if(/auto|scroll|hidden|clip/.test(style.overflowX)){b.left=Math.max(b.left,r.left+p.clientLeft);b.right=Math.min(b.right,r.left+p.clientLeft+p.clientWidth);}
}return b;
};
const condition=(c,frame)=>{
if(!c)return false;frame=c.frame??frame;
if(c.kind==='visibleText')return query('body *',frame).some(e=>shown(e)&&normalize(e.textContent)===normalize(c.value));
const els=c.target?locate(c.target,'click',frame):query(c.selector,frame),e=els[0];
if(c.kind==='any')return c.conditions.some(x=>condition(x,frame));
if(c.kind==='all')return c.conditions.every(x=>condition(x,frame));
if(c.kind==='absent')return !e||!shown(e);
if(c.kind==='visible')return shown(e);
if(c.kind==='count')return els.filter(shown).length===c.value;
if(c.kind==='text')return !!e&&e.textContent.trim()===c.value;
if(c.kind==='includes')return !!e&&e.textContent.includes(c.value);
if(c.kind==='attribute')return !!e&&e.getAttribute(c.attribute)===c.value;
if(c.kind==='focused')return !!e&&e.ownerDocument.activeElement===e;
if(c.kind==='property')return !!e&&e[c.property]===c.value;
if(c.kind==='value')return same(value(e),c.value);
return false;
};
const openDialogs=query('dialog[open],[role="dialog"][aria-modal="true"]').filter(shown),dialog=openDialogs.at(-1);
const controls=task.controls.map(c=>{
let els=c.target?locate(c.target,c.op,c.frame):query(c.selector,c.frame);const e=els[0],frameElement=c.frame?document.querySelector(c.frame):null,visible=shown(e)&&(!c.frame||shown(frameElement))&&(!c.when||condition(c.when,c.frame));
if(!e)return {...c,count:0,visible:false};
let effectiveValue=c.value,effectiveOp=c.op;
if(c.selectByLabel&&els.length===1){
if(e.tagName!=='SELECT')throw Error('Named select requires a native select');
const selected=(Array.isArray(c.value)?c.value:[c.value]).map(wanted=>{
const options=Array.from(e.options).filter(o=>normalize(o.textContent)===normalize(wanted));
if(options.length!==1)throw Error('Option label is missing or ambiguous: '+String(wanted));return options[0].value;
});
if(e.multiple){effectiveOp='multiselect';effectiveValue=selected;}
else {if(selected.length!==1)throw Error('A single select needs one option');effectiveValue=selected[0];}
}
c={...c,op:effectiveOp,value:effectiveValue,selector:c.target?css(e):c.selector};
c.purpose=c.purpose==='reveal'||!c.purpose?(['fill','type','clear','select','multiselect','check','uncheck','radio','upload'].includes(c.op)?'input':'interaction'):c.purpose;
const r=e.getBoundingClientRect(),bounds=viewBounds(e),fr=frameElement?.getBoundingClientRect();
let above=r.top<bounds.top-1||r.left<bounds.left-1,below=r.bottom>bounds.bottom+1||r.right>bounds.right+1;
if(frameElement){const outer=viewBounds(frameElement);above||=r.top+fr.top<outer.top-1||r.left+fr.left<outer.left-1;below||=r.bottom+fr.top>outer.bottom+1||r.right+fr.left>outer.right+1;}
const viewport=above?'above':below?'below':'inside';
let matched=false;
if(c.expect)matched=condition(c.expect,c.frame);
else if(c.op==='check'||c.op==='radio')matched=e.checked===true;
else if(c.op==='uncheck')matched=e.checked===false;
else if(c.op==='clear')matched=value(e)==='';
else if(c.op==='multiselect')matched=same([...value(e)].sort(),[...c.value].sort());
else if(c.op==='upload')matched=same(value(e).map(f=>f.name).sort(),(Array.isArray(c.value)?c.value:[c.value]).map(p=>p.split('/').at(-1)).sort());
else if(['fill','type','select'].includes(c.op))matched=same(value(e),c.finalValue??c.value);
const current=matched?'match':value(e)===''||value(e)===false?'empty':'wrong';
const enabled=!e.disabled&&e.getAttribute('aria-disabled')!=='true'&&!e.closest('[inert]')&&(!['fill','type','clear'].includes(c.op)||!e.readOnly);
const nativeRole=e.getAttribute('role')||(e.tagName==='SELECT'?(e.multiple?'listbox':'combobox'):e.tagName==='TEXTAREA'?'textbox':e.tagName==='INPUT'?({number:'spinbutton',checkbox:'checkbox',radio:'radio',file:'button'}[e.type]??'textbox'):e.tagName==='A'?'link':'button');
return {...c,role:c.role??nativeRole,count:els.length,visible,current,enabled,viewport,blocked:!!dialog&&!dialog.contains(frameElement??e),geometry:[r.x,r.y,r.width,r.height].map(n=>Math.round(n*4)/4),type:e.type??null,href:e.tagName==='A'?e.href:null,actual:value(e)};
});
const targets=controls.filter(c=>c.visible).flatMap(c=>query(c.selector,c.frame));
const animated=new Set();for(const e of targets)for(let p=e;p;p=p.parentElement)animated.add(p);
const animating=Array.from(animated).some(e=>(e.getAnimations?.()??[]).some(a=>a.playState==='running'&&Number.isFinite(a.effect?.getComputedTiming().endTime)));
return {origin:location.origin,animating,geometry:controls.map(c=>[c.id,c.count,c.visible,c.geometry]),controls,errors:query(task.errorSelector??'[role="alert"]').filter(shown).map(e=>e.textContent.trim()).filter(Boolean),busy:query(task.busySelector??'[aria-busy="true"]').some(shown),success:condition(task.success),modal:controls.some(c=>c.visible&&!c.blocked&&c.purpose==='dismiss')};
}
export function validateTask(t){
if(!t||typeof t!=='object'||!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(t.name??''))throw Error('Use a simple task name (letters, digits, dash, underscore)');
if(!t.url||!['http:','https:'].includes(new URL(t.url).protocol)||!Array.isArray(t.allowedOrigins)||!t.allowedOrigins.includes(new URL(t.url).origin))throw Error('Invalid task URL or allowed origins');
if(t.steps){if(t.controls!==undefined)throw Error('Use either steps or controls');new Workflow(t);workflowSuccess(t.expect);}
else {
if(!Array.isArray(t.controls)||!t.controls.length||!t.success)throw Error('Controls and an explicit success condition are required');
if(t.controls.some(c=>!OPS.has(c.op)||(!c.selector&&!c.target)||!c.label||!c.id))throw Error('Invalid control');
for(const c of t.controls)if(c.target)validateTarget(c.target);
if(new Set(t.controls.map(c=>c.id)).size!==t.controls.length)throw Error('Duplicate control IDs');
}
return t;
}
export function localEndpoint(endpoint){
const u=new URL(endpoint);
if(u.protocol!=='http:'||!['127.0.0.1','localhost','[::1]'].includes(u.hostname)||u.username||u.password||u.pathname!=='/'||u.search||u.hash)throw Error('A local loopback model endpoint is required');
return u.origin;
}
export function summaryOf(report){
const decisions=report.trace.filter(r=>r.request);
const usageKnown=decisions.every(r=>Number.isFinite(r.execution?.input_tokens));
return {name:report.name,mode:report.mode,status:report.status,error:report.error,elapsedMs:Math.round(report.elapsedMs),
workflow:report.workflow,metrics:{modelDecisions:decisions.length,executedActions:report.trace.filter(r=>r.executed&&r.action!=='WAIT').length,
deterministicActions:report.trace.filter(r=>r.executed&&r.source==='deterministic').length,
blockedRetries:report.trace.filter(r=>r.retry==='blocked').length,browserCalls:report.browserLoopCalls,
browserMs:Math.round(report.browserLoopMs),modelMs:Math.round(decisions.reduce((n,r)=>n+r.apiMs,0)),
inputTokens:usageKnown?decisions.reduce((n,r)=>n+r.execution.input_tokens,0):null,outputTokens:0,localApiCostUsd:0},
visibleSuccess:report.final?.success??false,submissionAttempted:report.submissionAttempted,missingTargets:report.final?.controls.filter(c=>c.count===0).map(c=>c.label)??[],
reportPath:report.reportPath,screenshot:report.screenshot};
}
export async function runTask(task,options={}){
task={...task,name:task.name??'browser-workflow',allowedOrigins:task.allowedOrigins??[new URL(task.url).origin]};
validateTask(task);
const {endpoint='http://127.0.0.1:8774',maxSteps=100,maxMs=600000,setup=true,blockedRetries=2,retryDelayMs=500,
mode='hybrid',onProgress=()=>{},decide:customDecide}=options;
for(const [k,v,min,max] of [['maxSteps',maxSteps,1,1000],['maxMs',maxMs,1,3600000],['blockedRetries',blockedRetries,0,5],['retryDelayMs',retryDelayMs,0,5000]])
if(!Number.isInteger(v)||v<min||v>max)throw Error('Invalid '+k);
if(!['model','hybrid'].includes(mode))throw Error('Mode must be model or hybrid');
const api=localEndpoint(endpoint),session=options.session??`nanojev-${task.name}-${Date.now()}`;
const browser=options.browser??new Browser(session,{origins:task.allowedOrigins});browser.allowedOrigins=task.allowedOrigins;
const outDir=options.outDir??resolve(ROOT,'.local/runs',`${task.name}-${Date.now()}`);await mkdir(outDir,{recursive:true,mode:0o700});
const flow=task.steps?new Workflow(task):null;
const success=flow?workflowSuccess(task.expect):task.success;
const activeTask=()=>({...task,controls:flow?flow.controls():task.controls,success});
const observe=async()=>{
const observation=await browser.observe(activeTask());
if(flow&&!flow.finished){observation.s.success=false;observation.dom.success=false;observation.state=stateText(observation.s);}
lastActionUnobserved=false;return observation;
};
if(setup){await browser.command(['open',task.url]);await browser.command(['set','viewport','1100','800']);}
const runnerSha256=hash(await readFile(fileURLToPath(import.meta.url),'utf8'));
const browserCallsAtStart=browser.calls,browserMsAtStart=browser.ms;
const start=performance.now(),trace=[],visits=new Map();let status='step_limit',error=null,blockedCount=0,modelIdentity=null,actionAttempted=false,lastActionUnobserved=false;const submitted=new Set();
const decide=customDecide??(async request=>{
const response=await fetch(api+'/api/evaluate',{method:'POST',redirect:'error',headers:{'Content-Type':'application/json'},body:JSON.stringify(request),signal:AbortSignal.timeout(Math.max(1,Math.min(60000,maxMs-(performance.now()-start))))});
const result=await response.json();if(!response.ok)throw Error(`NanoJev inference HTTP ${response.status}: ${result.error}`);return result;
});
try{
for(let step=0;step<maxSteps;step++){
if(performance.now()-start>=maxMs){status='time_limit';break;}
const observation=await observe();
if(flow?.advance(observation)){step--;continue;}
observation.actions=observation.actions.filter(a=>!submitted.has(a.id)&&(a.op==='scroll'||a.viewport==='inside')&&!(a.current==='match'&&a.expected==='assigned'&&['input','interaction','dismiss','reveal'].includes(a.purpose)));
const criteria=Object.fromEntries(observation.actions.map((a,i)=>[`a${i}`,description(a)]));Object.assign(criteria,SPECIALS);
let request=null,result=null,answer,a=null,apiMs=0,source='model';
// Default hybrid mode only shortcuts explicitly assigned native input mechanics.
// Clicks, submission, scrolling, waits and stopping remain model decisions.
const direct=mode==='hybrid'&&!observation.s.busy&&observation.actions.find(a=>a.current!=='match'&&a.expected==='assigned'&&a.purpose==='input'&&['fill','select','multiselect','check','uncheck','radio','clear'].includes(a.op)&&a.viewport==='inside');
if(direct){a=direct;answer={choice:'a'+observation.actions.indexOf(a)};source='deterministic';}
else {
request={states:[{id:'browser',state:observation.state,questions:{next:{type:'choice',instructions:INSTRUCTION,criteria}}}]};
const then=performance.now();result=await decide(request);apiMs=performance.now()-then;
answer=result.states?.[0]?.answers?.next;
const identity=result.checkpoint?.id;
if(!identity||identity!=='NanoJev-Web/browser-head-v5'||modelIdentity&&modelIdentity!==identity)throw Error('Unexpected or changed model checkpoint');modelIdentity=identity;
if(!answer||!Object.hasOwn(criteria,answer.choice))throw Error('Invalid model choice');
a=/^a\d+$/.test(answer.choice)?observation.actions[Number(answer.choice.slice(1))]:null;
}
const row={step:trace.length+1,stage:flow?flow.index+1:null,source,request,answer,execution:result?.execution,apiMs,action:a??answer.choice,before:observation.dom,executed:false};trace.push(row);
const fresh=source==='deterministic'?observation:await observe();row.afterDecision=fresh.dom;
if(performance.now()-start>=maxMs){status='time_limit';break;}
if(fresh.signature!==observation.signature){row.skipped='stale_observation';continue;}
if(answer.choice==='DONE'){status=fresh.s.success?'completed':'premature_done';break;}
if(answer.choice==='BLOCKED'){
if(blockedCount<blockedRetries){blockedCount++;row.retry='blocked';row.retryNumber=blockedCount;row.retryDelayMs=retryDelayMs;onProgress({event:'blocked_retry',attempt:blockedCount,delayMs:retryDelayMs});await sleep(Math.min(retryDelayMs,Math.max(0,maxMs-(performance.now()-start))));continue;}
status='model_blocked';break;
}
const key=hash([flow?.index,fresh.s,a?.op??answer.choice,a?.selector,a?.current,a?.viewport]);
visits.set(key,(visits.get(key)??0)+1);
if(answer.choice!=='WAIT'&&visits.get(key)>3){status='action_cycle';break;}
if(answer.choice==='WAIT'){await sleep(Math.min(retryDelayMs||100,Math.max(0,maxMs-(performance.now()-start))));row.executed=true;}
else {
if(fresh.s.busy){row.skipped='page_busy';await sleep(100);continue;}
actionAttempted=true;if(a.purpose==='submit')submitted.add(a.id);await browser.act(a);actionAttempted=false;lastActionUnobserved=true;row.executed=true;blockedCount=0;flow?.acted(a);
}
onProgress({event:'action',step:row.step,source,choice:answer.choice,op:a?.op??'WAIT',label:a?.label});
}
}catch(e){status=actionAttempted||lastActionUnobserved?'action_uncertain':'error';error=String(e.message);}
const elapsedMs=performance.now()-start,browserLoopCalls=browser.calls-browserCallsAtStart,browserLoopMs=browser.ms-browserMsAtStart;let final=null,screenshot=null,evidenceError=null;
try{final=(await observe()).dom;if(options.screenshot!==false){screenshot=resolve(outDir,task.name+'.png');await browser.command(['screenshot','--full',screenshot]);}}
catch(e){evidenceError=String(e.message);screenshot=null;if(status==='completed'){status='error';error='Could not verify final browser state: '+evidenceError;}}
const reportPath=resolve(outDir,task.name+'.json');
const report={name:task.name,session,submissionAttempted:submitted.size>0,status,error,evidenceError,mode,modelIdentity,runnerSha256,elapsedMs,browserLoopCalls,browserLoopMs,browserCalls:browser.calls,browserMs:browser.ms,agentBrowserStreamKeyboardEvents:browser.streamKeyboardEvents??0,taskHash:hash(task),trace,final,screenshot,reportPath,
workflow:flow?{completedSteps:flow.index,totalSteps:flow.steps.length,currentStep:flow.finished?null:flow.index+1,completed:flow.completed}:null};
await writeFile(reportPath,JSON.stringify(report,null,2),{mode:0o600});return {report,summary:summaryOf(report),browser};
}
export {Workflow, workflowSuccess};