fix(bridge): approval ENOENT race condition + multi-choice button grouping #task-276 #task-277
This commit is contained in:
@@ -284,3 +284,16 @@
|
|||||||
- **원인**: Discord.py View의 기본 timeout이 300초(5분)로 설정됨
|
- **원인**: Discord.py View의 기본 timeout이 300초(5분)로 설정됨
|
||||||
- **해결**: timeout을 1800초(30분)로 증가
|
- **해결**: timeout을 1800초(30분)로 증가
|
||||||
- **주의**: Discord View timeout은 서버 재시작 후만 적용. 기존 메시지의 View는 이미 만료됨
|
- **주의**: Discord View timeout은 서버 재시작 후만 적용. 기존 메시지의 View는 이미 만료됨
|
||||||
|
|
||||||
|
### [2026-03-10] DOM Observer 승인 ENOENT — response 파일 Race Condition
|
||||||
|
- **증상**: Discord에서 ✅승인 → extension.log에 `ENOENT: response/xxx.json` 에러 → AG에서 Run 미실행 (간헐적)
|
||||||
|
- **원인**: `processResponseFile()` (response watcher)이 response 파일을 읽고 즉시 `fs.unlinkSync()` → renderer `pollResponse()`가 `GET /response/:rid`로 조회할 때 파일 이미 없음
|
||||||
|
- **해결**: DOM observer 경로(`isDomObserver`)일 때 `processResponseFile()`에서 response 파일을 삭제하지 않고, HTTP handler(`GET /response/:rid`)가 renderer에 전달 후 삭제. step_probe 경로는 `clickTrigger`로 처리하므로 기존처럼 삭제 OK
|
||||||
|
- **주의**: DOM observer와 step_probe 두 경로가 독립적으로 pending을 생성할 수 있으므로, response 삭제 타이밍을 경로별로 구분해야 함
|
||||||
|
|
||||||
|
### [2026-03-10] Allow Once + Allow This Conversation — 별개 pending으로 분리되는 문제
|
||||||
|
- **증상**: 파일 접근 시 Allow Once와 Allow This Conversation이 Discord에 2개 별도 메시지로 도착. Deny 선택지 없음
|
||||||
|
- **원인**: renderer `scan()`이 한 사이클에 한 버튼만 처리 후 `return`. 각 버튼이 별도 pending으로 전송. Deny는 PATS 패턴에 미등록
|
||||||
|
- **해결**: (1) `scan()`에서 `findButtonContainer()`+`collectSiblingButtons()`로 같은 컨테이너의 관련 버튼을 그룹화하여 `buttons` 배열로 전송, (2) `bot.py` `ApprovalView`가 `buttons` 배열이 있으면 `clear_items()` 후 동적 Discord 버튼 추가, (3) `bridge.py` `UserResponse`에 `button_index` 추가, (4) renderer `pollResponseGroup()`에서 `button_index`에 따라 DOM 버튼 클릭
|
||||||
|
- **주의**: `buttons` 배열이 없는 legacy pending은 기존 2버튼(✅승인/❌거부)으로 표시. Deny를 PATS에 추가함
|
||||||
|
|
||||||
|
|||||||
97
bot.py
97
bot.py
@@ -32,16 +32,77 @@ logger = logging.getLogger(__name__)
|
|||||||
# ─── Discord UI Components ──────────────────────────────────────────
|
# ─── Discord UI Components ──────────────────────────────────────────
|
||||||
|
|
||||||
class ApprovalView(discord.ui.View):
|
class ApprovalView(discord.ui.View):
|
||||||
"""Discord buttons for approving/rejecting Antigravity actions."""
|
"""Discord buttons for approving/rejecting Antigravity actions.
|
||||||
|
|
||||||
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest):
|
Supports two modes:
|
||||||
|
1. Legacy: ✅ 승인 / ❌ 거부 (when no buttons array)
|
||||||
|
2. Multi-choice: dynamic buttons from pending's buttons array
|
||||||
|
(e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest,
|
||||||
|
buttons: list[dict] | None = None):
|
||||||
super().__init__(timeout=1800) # 30 minutes
|
super().__init__(timeout=1800) # 30 minutes
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
self.request = request
|
self.request = request
|
||||||
self.responded = False
|
self.responded = False
|
||||||
|
self.buttons_data = buttons
|
||||||
|
|
||||||
|
if buttons and len(buttons) > 1:
|
||||||
|
# Multi-choice mode: remove the default decorated buttons first
|
||||||
|
# (they are added by @discord.ui.button at class definition time)
|
||||||
|
self.clear_items()
|
||||||
|
|
||||||
|
# Add a Discord button for each option
|
||||||
|
for btn_info in buttons:
|
||||||
|
btn_text = btn_info.get("text", "?")
|
||||||
|
btn_index = btn_info.get("index", 0)
|
||||||
|
is_reject = btn_text.lower() in ("deny", "reject", "cancel",
|
||||||
|
"reject all", "decline",
|
||||||
|
"dismiss", "stop")
|
||||||
|
style = discord.ButtonStyle.red if is_reject else discord.ButtonStyle.green
|
||||||
|
emoji = "❌" if is_reject else "✅"
|
||||||
|
|
||||||
|
button = discord.ui.Button(
|
||||||
|
label=f"{emoji} {btn_text}",
|
||||||
|
style=style,
|
||||||
|
custom_id=f"choice_{request.request_id}_{btn_index}",
|
||||||
|
)
|
||||||
|
# Bind the callback with closure over btn_index and btn_text
|
||||||
|
button.callback = self._make_choice_callback(btn_index, btn_text,
|
||||||
|
is_reject)
|
||||||
|
self.add_item(button)
|
||||||
|
# else: use the default @discord.ui.button decorated methods below
|
||||||
|
|
||||||
|
def _make_choice_callback(self, btn_index: int, btn_text: str,
|
||||||
|
is_reject: bool):
|
||||||
|
async def callback(interaction: discord.Interaction):
|
||||||
|
if self.responded:
|
||||||
|
await interaction.response.send_message("이미 응답됨",
|
||||||
|
ephemeral=True)
|
||||||
|
return
|
||||||
|
self.responded = True
|
||||||
|
self.bridge.write_response(UserResponse(
|
||||||
|
request_id=self.request.request_id,
|
||||||
|
approved=not is_reject,
|
||||||
|
button_index=btn_index,
|
||||||
|
))
|
||||||
|
embed = interaction.message.embeds[0] if interaction.message.embeds else None
|
||||||
|
if embed:
|
||||||
|
color = discord.Color.red() if is_reject else discord.Color.green()
|
||||||
|
embed.color = color
|
||||||
|
emoji = "❌" if is_reject else "✅"
|
||||||
|
embed.set_footer(
|
||||||
|
text=f"{emoji} {btn_text} by {interaction.user.display_name}"
|
||||||
|
)
|
||||||
|
await interaction.response.edit_message(embed=embed, view=None)
|
||||||
|
return callback
|
||||||
|
|
||||||
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
|
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
|
||||||
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
# Only active in legacy mode (no buttons array)
|
||||||
|
if self.buttons_data and len(self.buttons_data) > 1:
|
||||||
|
return # multi-choice mode handles via dynamic buttons
|
||||||
if self.responded:
|
if self.responded:
|
||||||
await interaction.response.send_message("이미 응답됨", ephemeral=True)
|
await interaction.response.send_message("이미 응답됨", ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -57,6 +118,9 @@ class ApprovalView(discord.ui.View):
|
|||||||
|
|
||||||
@discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
|
@discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
|
||||||
async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
|
async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
|
||||||
|
# Only active in legacy mode (no buttons array)
|
||||||
|
if self.buttons_data and len(self.buttons_data) > 1:
|
||||||
|
return # multi-choice mode handles via dynamic buttons
|
||||||
if self.responded:
|
if self.responded:
|
||||||
await interaction.response.send_message("이미 응답됨", ephemeral=True)
|
await interaction.response.send_message("이미 응답됨", ephemeral=True)
|
||||||
return
|
return
|
||||||
@@ -516,21 +580,38 @@ class GravityBot(commands.Bot):
|
|||||||
async def _send_approval_request(
|
async def _send_approval_request(
|
||||||
self, channel: discord.TextChannel, request: ApprovalRequest
|
self, channel: discord.TextChannel, request: ApprovalRequest
|
||||||
):
|
):
|
||||||
|
# Read buttons array from pending file (if present)
|
||||||
|
buttons = None
|
||||||
|
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
||||||
|
if pending_file.exists():
|
||||||
|
try:
|
||||||
|
pending_data = json.loads(
|
||||||
|
pending_file.read_text(encoding="utf-8-sig")
|
||||||
|
)
|
||||||
|
buttons = pending_data.get("buttons")
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build embed description
|
||||||
|
desc_parts = [f"**명령어:**\n```\n{request.command[:1000]}\n```"]
|
||||||
|
if buttons and len(buttons) > 1:
|
||||||
|
# Multi-choice: show all options in description
|
||||||
|
btn_names = [b.get("text", "?") for b in buttons]
|
||||||
|
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
|
||||||
|
if request.description:
|
||||||
|
desc_parts.append(request.description[:500])
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="⚠️ 승인 요청",
|
title="⚠️ 승인 요청",
|
||||||
description=(
|
description="\n".join(desc_parts),
|
||||||
f"**명령어:**\n```\n{request.command[:1000]}\n```\n"
|
|
||||||
f"{request.description[:500]}"
|
|
||||||
),
|
|
||||||
color=discord.Color.orange(),
|
color=discord.Color.orange(),
|
||||||
timestamp=datetime.now(timezone.utc),
|
timestamp=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
embed.set_footer(text=f"ID: {request.request_id}")
|
embed.set_footer(text=f"ID: {request.request_id}")
|
||||||
|
|
||||||
view = ApprovalView(self.bridge, request)
|
view = ApprovalView(self.bridge, request, buttons=buttons)
|
||||||
msg = await channel.send(embed=embed, view=view)
|
msg = await channel.send(embed=embed, view=view)
|
||||||
|
|
||||||
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
|
|
||||||
if pending_file.exists():
|
if pending_file.exists():
|
||||||
try:
|
try:
|
||||||
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class UserResponse:
|
|||||||
approved: bool
|
approved: bool
|
||||||
user_input: str = ""
|
user_input: str = ""
|
||||||
timestamp: float = 0
|
timestamp: float = 0
|
||||||
|
button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index
|
||||||
|
|
||||||
|
|
||||||
class BridgeProtocol:
|
class BridgeProtocol:
|
||||||
|
|||||||
5
docs/devlog/2026-03-10.md
Normal file
5
docs/devlog/2026-03-10.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-03-10 Devlog
|
||||||
|
|
||||||
|
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|
||||||
|
|---|------|----------|------|------|
|
||||||
|
| 001 | 06:12~06:30 | Discord 승인 ENOENT race condition 수정 + 버튼 그룹화 (multi-choice) | `` | 🔧 |
|
||||||
14
docs/devlog/entries/20260310-001.md
Normal file
14
docs/devlog/entries/20260310-001.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Discord 승인 ENOENT 수정 + 버튼 그룹화 (multi-choice)
|
||||||
|
|
||||||
|
- **시간**: 2026-03-10 06:12 ~ 06:30
|
||||||
|
- **Commit**: ``
|
||||||
|
- **Vikunja**: #276 → 진행중 (E2E 검증 대기)
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
- **ENOENT 근본 원인**: extension.log에서 `[RESPONSE] error: ENOENT` 확인. `processResponseFile()`이 DOM observer response를 renderer가 polling하기 전에 삭제. DOM observer 경로만 response 보존, HTTP handler에서 삭제하도록 변경
|
||||||
|
- **버튼 그룹화**: `findButtonContainer()` + `collectSiblingButtons()`로 같은 컨테이너의 관련 버튼을 하나의 pending으로 묶음. `buttons` 배열 + `button_index` response로 Discord에서 원하는 버튼만 정확히 클릭
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
- AG 재시작 + V8 CachedData 삭제 후 E2E 검증 필요
|
||||||
|
- Run 승인 반복 테스트 (5회 이상)
|
||||||
|
- Allow Once / Allow This Conversation / Deny 3개 선택지 동시 출현 확인
|
||||||
@@ -492,7 +492,7 @@ function startObserverHttpBridge() {
|
|||||||
source: 'dom_observer',
|
source: 'dom_observer',
|
||||||
};
|
};
|
||||||
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
||||||
}
|
}
|
||||||
@@ -906,10 +906,14 @@ function generateApprovalObserverScript(_port) {
|
|||||||
{re:/^Accept$/i, type:'agent_step'},
|
{re:/^Accept$/i, type:'agent_step'},
|
||||||
{re:/^Allow/i, type:'permission'},
|
{re:/^Allow/i, type:'permission'},
|
||||||
{re:/^Approve/i, type:'agent_step'},
|
{re:/^Approve/i, type:'agent_step'},
|
||||||
|
{re:/^Deny$/i, type:'permission'},
|
||||||
{re:/^Retry$/i, type:'error_recovery'},
|
{re:/^Retry$/i, type:'error_recovery'},
|
||||||
{re:/^Dismiss$/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
|
// 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];
|
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
|
||||||
|
|
||||||
@@ -954,6 +958,41 @@ function generateApprovalObserverScript(_port) {
|
|||||||
return desc.substring(0,500);
|
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) ──
|
// ── Find the React app container (Antigravity's main UI root) ──
|
||||||
function findPanel(){
|
function findPanel(){
|
||||||
// Priority order of panel selectors (most specific first)
|
// Priority order of panel selectors (most specific first)
|
||||||
@@ -972,6 +1011,7 @@ function generateApprovalObserverScript(_port) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Core scan — finds actionable buttons and reports to bridge ──
|
// ── Core scan — finds actionable buttons and reports to bridge ──
|
||||||
|
// Groups related buttons from same container into a single pending
|
||||||
function scan(){
|
function scan(){
|
||||||
if(!_ready)return;
|
if(!_ready)return;
|
||||||
var now=Date.now();
|
var now=Date.now();
|
||||||
@@ -1009,78 +1049,122 @@ function generateApprovalObserverScript(_port) {
|
|||||||
}
|
}
|
||||||
if(!matchedType)continue;
|
if(!matchedType)continue;
|
||||||
|
|
||||||
// Generate stable ID
|
// Generate stable ID for the GROUP (use container-based key)
|
||||||
var bid=btnId(b,matchedType);
|
var container=findButtonContainer(b);
|
||||||
if(_sent[bid])continue;
|
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 desc=extractContext(b);
|
||||||
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
||||||
|
|
||||||
// Mark as sent
|
// Mark entire group as sent
|
||||||
_sent[bid]={rid:rid,ts:now};
|
_sent[groupKey]={rid:rid,ts:now};
|
||||||
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
|
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)
|
// Send to bridge (closure to capture refs)
|
||||||
(function(rid2,b2,bid2,txt2,desc2,type2){
|
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
|
||||||
fetch(BASE+'/pending',{
|
var payload={
|
||||||
method:'POST',
|
|
||||||
headers:{'Content-Type':'application/json'},
|
|
||||||
body:JSON.stringify({
|
|
||||||
request_id:rid2,
|
request_id:rid2,
|
||||||
command:txt2,
|
command:txt2,
|
||||||
description:desc2,
|
description:desc2,
|
||||||
step_type:type2
|
step_type:type2,
|
||||||
})
|
buttons:buttonsArr2
|
||||||
|
};
|
||||||
|
fetch(BASE+'/pending',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify(payload)
|
||||||
}).then(function(r){return r.json();}).then(function(d){
|
}).then(function(r){return r.json();}).then(function(d){
|
||||||
log('Pending created: '+d.request_id+' for "'+txt2+'"');
|
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
||||||
pollResponse(d.request_id,b2,bid2);
|
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
||||||
}).catch(function(e){
|
}).catch(function(e){
|
||||||
log('POST error: '+e.message);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} // end searchRoots loop
|
} // end searchRoots loop
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Poll for Discord response ──
|
// ── Poll for Discord response (multi-button group aware) ──
|
||||||
function pollResponse(rid,btn,bid){
|
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
|
||||||
var polls=0;
|
var polls=0;
|
||||||
var maxPolls=600; // 5 minutes at 500ms interval
|
var maxPolls=600; // 5 minutes at 500ms interval
|
||||||
var timer=setInterval(function(){
|
var timer=setInterval(function(){
|
||||||
polls++;
|
polls++;
|
||||||
// Check if button is still in DOM (step may have been resolved by other means)
|
// Check if ANY button in the group is still in DOM
|
||||||
if(!document.body.contains(btn)){
|
var anyAlive=false;
|
||||||
log('Button removed from DOM — stopping poll for '+rid);
|
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);
|
clearInterval(timer);
|
||||||
delete _sent[bid];
|
delete _sent[groupKey];
|
||||||
|
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(polls>maxPolls){
|
if(polls>maxPolls){
|
||||||
log('Poll timeout for '+rid);
|
log('Poll timeout for '+rid);
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
delete _sent[bid];
|
delete _sent[groupKey];
|
||||||
|
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
||||||
if(d.waiting)return;
|
if(d.waiting)return;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
if(d.approved){
|
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
|
||||||
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
|
if(btnIdx>=0&&btnIdx<btnRefs.length){
|
||||||
btn.click();
|
// 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 {
|
} else {
|
||||||
|
// Legacy reject: find and click reject/deny button
|
||||||
log('❌ REJECTED '+rid+' → finding reject 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(){});
|
}).catch(function(){});
|
||||||
},500);
|
},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 ──
|
// ── Find and click the reject/cancel counterpart button ──
|
||||||
function clickRejectButton(approveBtn){
|
function clickRejectButton(approveBtn){
|
||||||
// Walk up to find the container, then search for reject buttons
|
// Walk up to find the container, then search for reject buttons
|
||||||
@@ -1703,11 +1787,17 @@ async function processResponseFile(filePath) {
|
|||||||
}
|
}
|
||||||
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
||||||
// Cleanup response file
|
// Cleanup response file
|
||||||
|
// 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 {
|
try {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
const log = `[RESPONSE] error: ${e.message}`;
|
const log = `[RESPONSE] error: ${e.message}`;
|
||||||
console.log(`Gravity Bridge: ${log}`);
|
console.log(`Gravity Bridge: ${log}`);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -471,7 +471,7 @@ function startObserverHttpBridge(): Promise<number> {
|
|||||||
source: 'dom_observer',
|
source: 'dom_observer',
|
||||||
};
|
};
|
||||||
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -885,10 +885,14 @@ function generateApprovalObserverScript(_port: number): string {
|
|||||||
{re:/^Accept$/i, type:'agent_step'},
|
{re:/^Accept$/i, type:'agent_step'},
|
||||||
{re:/^Allow/i, type:'permission'},
|
{re:/^Allow/i, type:'permission'},
|
||||||
{re:/^Approve/i, type:'agent_step'},
|
{re:/^Approve/i, type:'agent_step'},
|
||||||
|
{re:/^Deny$/i, type:'permission'},
|
||||||
{re:/^Retry$/i, type:'error_recovery'},
|
{re:/^Retry$/i, type:'error_recovery'},
|
||||||
{re:/^Dismiss$/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
|
// 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];
|
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);
|
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) ──
|
// ── Find the React app container (Antigravity's main UI root) ──
|
||||||
function findPanel(){
|
function findPanel(){
|
||||||
// Priority order of panel selectors (most specific first)
|
// 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 ──
|
// ── Core scan — finds actionable buttons and reports to bridge ──
|
||||||
|
// Groups related buttons from same container into a single pending
|
||||||
function scan(){
|
function scan(){
|
||||||
if(!_ready)return;
|
if(!_ready)return;
|
||||||
var now=Date.now();
|
var now=Date.now();
|
||||||
@@ -988,78 +1028,122 @@ function generateApprovalObserverScript(_port: number): string {
|
|||||||
}
|
}
|
||||||
if(!matchedType)continue;
|
if(!matchedType)continue;
|
||||||
|
|
||||||
// Generate stable ID
|
// Generate stable ID for the GROUP (use container-based key)
|
||||||
var bid=btnId(b,matchedType);
|
var container=findButtonContainer(b);
|
||||||
if(_sent[bid])continue;
|
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 desc=extractContext(b);
|
||||||
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
||||||
|
|
||||||
// Mark as sent
|
// Mark entire group as sent
|
||||||
_sent[bid]={rid:rid,ts:now};
|
_sent[groupKey]={rid:rid,ts:now};
|
||||||
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
|
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)
|
// Send to bridge (closure to capture refs)
|
||||||
(function(rid2,b2,bid2,txt2,desc2,type2){
|
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
|
||||||
fetch(BASE+'/pending',{
|
var payload={
|
||||||
method:'POST',
|
|
||||||
headers:{'Content-Type':'application/json'},
|
|
||||||
body:JSON.stringify({
|
|
||||||
request_id:rid2,
|
request_id:rid2,
|
||||||
command:txt2,
|
command:txt2,
|
||||||
description:desc2,
|
description:desc2,
|
||||||
step_type:type2
|
step_type:type2,
|
||||||
})
|
buttons:buttonsArr2
|
||||||
|
};
|
||||||
|
fetch(BASE+'/pending',{
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify(payload)
|
||||||
}).then(function(r){return r.json();}).then(function(d){
|
}).then(function(r){return r.json();}).then(function(d){
|
||||||
log('Pending created: '+d.request_id+' for "'+txt2+'"');
|
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
||||||
pollResponse(d.request_id,b2,bid2);
|
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
||||||
}).catch(function(e){
|
}).catch(function(e){
|
||||||
log('POST error: '+e.message);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} // end searchRoots loop
|
} // end searchRoots loop
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Poll for Discord response ──
|
// ── Poll for Discord response (multi-button group aware) ──
|
||||||
function pollResponse(rid,btn,bid){
|
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
|
||||||
var polls=0;
|
var polls=0;
|
||||||
var maxPolls=600; // 5 minutes at 500ms interval
|
var maxPolls=600; // 5 minutes at 500ms interval
|
||||||
var timer=setInterval(function(){
|
var timer=setInterval(function(){
|
||||||
polls++;
|
polls++;
|
||||||
// Check if button is still in DOM (step may have been resolved by other means)
|
// Check if ANY button in the group is still in DOM
|
||||||
if(!document.body.contains(btn)){
|
var anyAlive=false;
|
||||||
log('Button removed from DOM — stopping poll for '+rid);
|
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);
|
clearInterval(timer);
|
||||||
delete _sent[bid];
|
delete _sent[groupKey];
|
||||||
|
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(polls>maxPolls){
|
if(polls>maxPolls){
|
||||||
log('Poll timeout for '+rid);
|
log('Poll timeout for '+rid);
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
delete _sent[bid];
|
delete _sent[groupKey];
|
||||||
|
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
||||||
if(d.waiting)return;
|
if(d.waiting)return;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
if(d.approved){
|
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
|
||||||
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
|
if(btnIdx>=0&&btnIdx<btnRefs.length){
|
||||||
btn.click();
|
// 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 {
|
} else {
|
||||||
|
// Legacy reject: find and click reject/deny button
|
||||||
log('❌ REJECTED '+rid+' → finding reject 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(){});
|
}).catch(function(){});
|
||||||
},500);
|
},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 ──
|
// ── Find and click the reject/cancel counterpart button ──
|
||||||
function clickRejectButton(approveBtn){
|
function clickRejectButton(approveBtn){
|
||||||
// Walk up to find the container, then search for reject buttons
|
// 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'})`);
|
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
||||||
|
|
||||||
// Cleanup response file
|
// Cleanup response file
|
||||||
|
// 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 { }
|
try { fs.unlinkSync(filePath); } catch { }
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const log = `[RESPONSE] error: ${e.message}`;
|
const log = `[RESPONSE] error: ${e.message}`;
|
||||||
console.log(`Gravity Bridge: ${log}`);
|
console.log(`Gravity Bridge: ${log}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user