fix(bridge): approval ENOENT race condition + multi-choice button grouping #task-276 #task-277
This commit is contained in:
@@ -471,7 +471,7 @@ function startObserverHttpBridge(): Promise<number> {
|
||||
source: 'dom_observer',
|
||||
};
|
||||
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
||||
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" ctx="${(data.description || '').substring(0, 50)}"`);
|
||||
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
||||
} catch (e: any) {
|
||||
@@ -885,10 +885,14 @@ function generateApprovalObserverScript(_port: number): string {
|
||||
{re:/^Accept$/i, type:'agent_step'},
|
||||
{re:/^Allow/i, type:'permission'},
|
||||
{re:/^Approve/i, type:'agent_step'},
|
||||
{re:/^Deny$/i, type:'permission'},
|
||||
{re:/^Retry$/i, type:'error_recovery'},
|
||||
{re:/^Dismiss$/i, type:'error_recovery'},
|
||||
];
|
||||
|
||||
// ALL actionable button patterns (for grouping siblings in same container)
|
||||
var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i];
|
||||
|
||||
// Reject button patterns for finding the counterpart
|
||||
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
|
||||
|
||||
@@ -933,6 +937,41 @@ function generateApprovalObserverScript(_port: number): string {
|
||||
return desc.substring(0,500);
|
||||
}
|
||||
|
||||
// ── Find common container of related buttons ──
|
||||
function findButtonContainer(btn){
|
||||
return btn.closest('[class*="step"]')
|
||||
||btn.closest('[class*="action"]')
|
||||
||btn.closest('[class*="tool"]')
|
||||
||btn.closest('[class*="cascade"]')
|
||||
||btn.closest('[class*="message"]')
|
||||
||btn.closest('[class*="dialog"]')
|
||||
||btn.closest('[class*="notification"]')
|
||||
||btn.parentElement;
|
||||
}
|
||||
|
||||
// ── Collect all actionable sibling buttons from a container ──
|
||||
function collectSiblingButtons(container,triggerBtn){
|
||||
if(!container)return [];
|
||||
var siblings=container.querySelectorAll('button');
|
||||
var result=[];
|
||||
for(var i=0;i<siblings.length;i++){
|
||||
var sb=siblings[i];
|
||||
if(sb.disabled||sb.hidden)continue;
|
||||
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
|
||||
var stxt=(sb.textContent||'').trim();
|
||||
stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
|
||||
if(!stxt)continue;
|
||||
// Check if this button matches any actionable pattern
|
||||
var isAction=false;
|
||||
for(var a=0;a<ALL_ACTION_RE.length;a++){
|
||||
if(ALL_ACTION_RE[a].test(stxt)){isAction=true;break;}
|
||||
}
|
||||
if(!isAction)continue;
|
||||
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Find the React app container (Antigravity's main UI root) ──
|
||||
function findPanel(){
|
||||
// Priority order of panel selectors (most specific first)
|
||||
@@ -951,6 +990,7 @@ function generateApprovalObserverScript(_port: number): string {
|
||||
}
|
||||
|
||||
// ── Core scan — finds actionable buttons and reports to bridge ──
|
||||
// Groups related buttons from same container into a single pending
|
||||
function scan(){
|
||||
if(!_ready)return;
|
||||
var now=Date.now();
|
||||
@@ -988,78 +1028,122 @@ function generateApprovalObserverScript(_port: number): string {
|
||||
}
|
||||
if(!matchedType)continue;
|
||||
|
||||
// Generate stable ID
|
||||
var bid=btnId(b,matchedType);
|
||||
if(_sent[bid])continue;
|
||||
// Generate stable ID for the GROUP (use container-based key)
|
||||
var container=findButtonContainer(b);
|
||||
var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
|
||||
if(_sent[groupKey])continue;
|
||||
|
||||
// Extract context
|
||||
// Collect ALL related buttons from the same container
|
||||
var siblings=collectSiblingButtons(container,b);
|
||||
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
|
||||
|
||||
// Build buttons array for multi-choice support
|
||||
var buttonsArr=[];
|
||||
var btnRefs=[];
|
||||
var bidList=[];
|
||||
for(var si=0;si<siblings.length;si++){
|
||||
var sb=siblings[si];
|
||||
var sbid=btnId(sb.btn,matchedType);
|
||||
buttonsArr.push({text:sb.text,index:si,is_primary:sb.isPrimary});
|
||||
btnRefs.push(sb.btn);
|
||||
bidList.push(sbid);
|
||||
}
|
||||
|
||||
// Extract context from trigger button
|
||||
var desc=extractContext(b);
|
||||
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
||||
|
||||
// Mark as sent
|
||||
_sent[bid]={rid:rid,ts:now};
|
||||
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
|
||||
// Mark entire group as sent
|
||||
_sent[groupKey]={rid:rid,ts:now};
|
||||
for(var mk=0;mk<bidList.length;mk++){_sent[bidList[mk]]={rid:rid,ts:now};}
|
||||
|
||||
log('DETECTED GROUP '+matchedType+': '+buttonsArr.map(function(x){return '"'+x.text+'"';}).join(', ')+' → pending to bridge');
|
||||
|
||||
// Send to bridge (closure to capture refs)
|
||||
(function(rid2,b2,bid2,txt2,desc2,type2){
|
||||
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
|
||||
var payload={
|
||||
request_id:rid2,
|
||||
command:txt2,
|
||||
description:desc2,
|
||||
step_type:type2,
|
||||
buttons:buttonsArr2
|
||||
};
|
||||
fetch(BASE+'/pending',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({
|
||||
request_id:rid2,
|
||||
command:txt2,
|
||||
description:desc2,
|
||||
step_type:type2
|
||||
})
|
||||
body:JSON.stringify(payload)
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
log('Pending created: '+d.request_id+' for "'+txt2+'"');
|
||||
pollResponse(d.request_id,b2,bid2);
|
||||
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
||||
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
||||
}).catch(function(e){
|
||||
log('POST error: '+e.message);
|
||||
delete _sent[bid2];
|
||||
delete _sent[groupKey2];
|
||||
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
|
||||
});
|
||||
})(rid,b,bid,txt,desc,matchedType);
|
||||
})(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
|
||||
|
||||
// Process ONE button per scan cycle (avoid flooding)
|
||||
// Process ONE button GROUP per scan cycle (avoid flooding)
|
||||
return;
|
||||
}
|
||||
} // end searchRoots loop
|
||||
}
|
||||
|
||||
// ── Poll for Discord response ──
|
||||
function pollResponse(rid,btn,bid){
|
||||
// ── Poll for Discord response (multi-button group aware) ──
|
||||
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
|
||||
var polls=0;
|
||||
var maxPolls=600; // 5 minutes at 500ms interval
|
||||
var timer=setInterval(function(){
|
||||
polls++;
|
||||
// Check if button is still in DOM (step may have been resolved by other means)
|
||||
if(!document.body.contains(btn)){
|
||||
log('Button removed from DOM — stopping poll for '+rid);
|
||||
// Check if ANY button in the group is still in DOM
|
||||
var anyAlive=false;
|
||||
for(var ai=0;ai<btnRefs.length;ai++){
|
||||
if(document.body.contains(btnRefs[ai])){anyAlive=true;break;}
|
||||
}
|
||||
if(!anyAlive){
|
||||
log('All buttons removed from DOM — stopping poll for '+rid);
|
||||
clearInterval(timer);
|
||||
delete _sent[bid];
|
||||
delete _sent[groupKey];
|
||||
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
|
||||
return;
|
||||
}
|
||||
if(polls>maxPolls){
|
||||
log('Poll timeout for '+rid);
|
||||
clearInterval(timer);
|
||||
delete _sent[bid];
|
||||
delete _sent[groupKey];
|
||||
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
|
||||
return;
|
||||
}
|
||||
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
||||
if(d.waiting)return;
|
||||
clearInterval(timer);
|
||||
if(d.approved){
|
||||
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
|
||||
btn.click();
|
||||
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
|
||||
if(btnIdx>=0&&btnIdx<btnRefs.length){
|
||||
// Multi-choice: click specific button by index
|
||||
var targetBtn=btnRefs[btnIdx];
|
||||
var targetTxt=(targetBtn.textContent||'').trim();
|
||||
log((d.approved?'✅':'❌')+' CHOICE '+rid+' → clicking button['+btnIdx+'] "'+targetTxt+'"');
|
||||
targetBtn.click();
|
||||
} else if(d.approved){
|
||||
// Legacy single-button: click first (primary) button
|
||||
var primaryBtn=btnRefs[0];
|
||||
log('✅ APPROVED '+rid+' → clicking "'+((primaryBtn.textContent||'').trim())+'"');
|
||||
primaryBtn.click();
|
||||
} else {
|
||||
// Legacy reject: find and click reject/deny button
|
||||
log('❌ REJECTED '+rid+' → finding reject button');
|
||||
clickRejectButton(btn);
|
||||
clickRejectButton(btnRefs[0]);
|
||||
}
|
||||
delete _sent[bid];
|
||||
delete _sent[groupKey];
|
||||
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
|
||||
}).catch(function(){});
|
||||
},500);
|
||||
}
|
||||
|
||||
// Legacy pollResponse for backward compatibility (single button)
|
||||
function pollResponse(rid,btn,bid){
|
||||
pollResponseGroup(rid,[btn],[bid],bid);
|
||||
}
|
||||
|
||||
// ── Find and click the reject/cancel counterpart button ──
|
||||
function clickRejectButton(approveBtn){
|
||||
// Walk up to find the container, then search for reject buttons
|
||||
@@ -1686,7 +1770,13 @@ async function processResponseFile(filePath: string) {
|
||||
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
||||
|
||||
// Cleanup response file
|
||||
try { fs.unlinkSync(filePath); } catch { }
|
||||
// CRITICAL: DOM observer responses must NOT be deleted here!
|
||||
// The renderer polls GET /response/:rid to discover the approval.
|
||||
// If we delete the file before the renderer polls, it gets ENOENT.
|
||||
// The HTTP handler (/response/:rid) deletes after serving to renderer.
|
||||
if (!isDomObserver) {
|
||||
try { fs.unlinkSync(filePath); } catch { }
|
||||
}
|
||||
} catch (e: any) {
|
||||
const log = `[RESPONSE] error: ${e.message}`;
|
||||
console.log(`Gravity Bridge: ${log}`);
|
||||
|
||||
Reference in New Issue
Block a user