From 962bb50a1c8c450987bfb4365175717a01b5e10f Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 15:14:37 +0800 Subject: [PATCH] feat: browser stream waits through round gaps instead of disconnecting Stale bridge state used to end the stream on the first stale read, so every round gap and every worker restart killed the watch (the 2026-10-30 demo watch died this way when a 15:03 deploy restarted the worker). Now a stale state switches to a waiting heartbeat every 5s while the task is still alive (running/queued/paused/ interrupted), and frames resume when the next round's MCP starts polling. The stream ends only on a terminal task state, a missing task row, or 10 minutes of continuous staleness. The panel shows a waiting status and keeps the last frame. Also force-close the SSE connection on stream end: HTTP/1.1 keep-alive otherwise left terminal streams hanging with no EOF. --- .../src/sections/detail/BrowserPanel.tsx | 16 ++++- cloud/console.html | 30 ++++---- cloud/server.py | 68 +++++++++++++++++-- cloud/tests/test_server_browser_proxy.py | 45 ++++++++++++ 4 files changed, 137 insertions(+), 22 deletions(-) diff --git a/cloud/console-react/src/sections/detail/BrowserPanel.tsx b/cloud/console-react/src/sections/detail/BrowserPanel.tsx index 21e440c..ff632c7 100644 --- a/cloud/console-react/src/sections/detail/BrowserPanel.tsx +++ b/cloud/console-react/src/sections/detail/BrowserPanel.tsx @@ -105,9 +105,23 @@ export default function BrowserPanel() { holder?: string; url?: string; title?: string; + waiting?: boolean; + task_status?: string; }; - if (payload.image) { + if (payload.waiting) { + // Round gap / worker restart: the server keeps + // the stream open instead of dropping the watch. + // Keep the last frame on screen, just say so. + setStatus( + payload.task_status === "interrupted" + ? "任务已中断,等待续跑…" + : payload.task_status === "paused" + ? "任务已暂停…" + : "等待会话…", + ); + } else if (payload.image) { setImg("data:image/jpeg;base64," + payload.image); + setStatus("直播中"); } const patch: BrowserState = {}; if (payload.holder) patch.holder = payload.holder; diff --git a/cloud/console.html b/cloud/console.html index 8331ec9..5a9e89a 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -4,7 +4,7 @@ Codex 命令台 - +`)}}p("流已断开")}catch(rt){if(rt.name==="AbortError")return;O(rt instanceof Error?rt.message:String(rt)),p("未开始")}finally{j(!1)}},[r]),L=d.useCallback(()=>{var it;(it=w.current)==null||it.abort(),j(!1),p("已停止")},[]),vt=d.useCallback(async it=>{if(r)try{const Q=await I(K.taskBrowserControl(r.id),"POST",{holder:it});C(Q),O("")}catch(Q){O(Q instanceof Error?Q.message:String(Q))}},[r]),bt=d.useCallback(async it=>{if(!r||(S==null?void 0:S.holder)!=="user"||!X.current)return;const Q=X.current.getBoundingClientRect(),rt=Math.round((it.clientX-Q.left)/Q.width*1280),Ut=Math.round((it.clientY-Q.top)/Q.height*800);try{await I(K.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:rt,y:Ut})}catch(Qt){O(Qt instanceof Error?Qt.message:String(Qt))}},[r,S]),zt=d.useCallback(async it=>{if(!(!r||(S==null?void 0:S.holder)!=="user")){it.preventDefault();try{it.key.length===1?await I(K.taskBrowserInput(r.id),"POST",{type:"type",text:it.key}):await I(K.taskBrowserInput(r.id),"POST",{type:"key",key:it.key})}catch(Q){O(Q instanceof Error?Q.message:String(Q))}}},[r,S]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const J=(S==null?void 0:S.holder)||"—",Rt=S!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",Rt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",J]}),S!=null&&S.url?c.jsx("span",{className:"muted",title:S.url,children:S.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:L,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void at(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void V(),children:"刷新状态"}),J!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Rt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),H?c.jsx("div",{className:"muted",style:{marginTop:8},children:H}):null,J==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:X,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:it=>void bt(it),onKeyDown:it=>void zt(it),style:{width:"100%",maxWidth:1280,cursor:J==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Rt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function xg(){const i=Zt(),[r,h]=d.useState("uncommittedChanges"),[f,S]=d.useState(""),[C,A]=d.useState(""),[p,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:O=>h(O.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:O=>S(O.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:O=>A(O.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:p,onChange:O=>H(O.target.value)}),c.jsxs(wt,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:p.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(O=>{let Y=O.target_type||"";return O.branch&&(Y+=":"+O.branch),O.sha&&(Y+=":"+String(O.sha).slice(0,12)),O.instructions&&(Y+=":"+String(O.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(O.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(O.status||""),children:O.status}),O.created_at?" | "+Ya(O.created_at):"",c.jsx("br",{}),O.findings?String(O.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(O.id),children:"写 findings"})]},O.id)})})]})}function jg(){const i=Zt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(cu,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",Ne(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Tg(){const i=Zt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(cu,{children:i.agents.map((f,S)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[f.kind||""]||f.kind||"",":"]}),f.name||""," ",c.jsx("span",{className:"st-"+(f.status||""),children:h[f.status||""]||f.status||"?"})," | 过程调用 "+(f.calls?f.calls.length:0)+" 条",f.updated_ts?" | 最近活动 "+Ya(f.updated_ts/1e3):"",f.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",f.prompt]})]}):null,f.calls&&f.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),f.calls.slice(-50).map((C,A)=>c.jsx("div",{children:vo(C)},A))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},S))})}function _g(){const i=Zt();return c.jsxs(c.Fragment,{children:[c.jsxs(wt,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(xl,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Ya(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function Eg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:C=>h(C.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:C=>S(C.target.value)}),c.jsxs(wt,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),S("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),S("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(xl,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((C,A)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(C.task_id).slice(0,8),"|",C.mode,"|",C.status,"] ",String(C.body||"").slice(0,120)]},A))})]})}function Ng(){const i=Zt(),[r,h]=d.useState(""),f=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:S=>h(S.target.value)}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(xl,{empty:"暂无其他线程",items:i.threadList.map((S,C)=>c.jsxs("span",{className:"row-main",children:[S.name||S.thread_name||S.id||""," [",S.id||"","]",S.archived?" [已归档]":"",S.cwd?" "+S.cwd:""]},C))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Cg(i){return/503|Service Unavailable/.test(i)?"上游模型服务暂时不可用(503):模型一轮都没跑出来,不是任务逻辑失败;等上游恢复或换模型重试":/429|rate limit/i.test(i)?"上游限流(429):模型拒绝了本次调用,可稍后重试":/timeout|timed out/i.test(i)?"上游调用超时:模型响应未在限时内返回":/401|403|Invalid API key|unauthorized/i.test(i)?"上游鉴权失败:网关 key 或权限有问题":/ECONNREFUSED|ENOTFOUND|network|fetch failed/i.test(i)?"连不上上游模型服务:网络或网关地址有问题":"任务失败(原因见原始错误文本)"}function Ag(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const Og=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function Mg(){const i=Zt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Jt,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[S,C]=Wh(r),[A,p]=$h(r),H=Fh(r),O=i.costSummary,Y=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式无敌版(不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:Og.map(([j,w])=>c.jsx("button",{type:"button",className:h===j?"active":"",onClick:()=>f(j),children:w},j))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Jt,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+H,"aria-hidden":"true",children:H}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",Fy[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Ya(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:C,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:S})]}),c.jsxs("div",{className:"detail-card",title:p,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:A})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:O===void 0?"加载中…":O===null?"—":(O.llm_requests??0).toLocaleString()}),O===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):O===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",O.turns??0," | fallback ",O.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:O===void 0?"加载中…":O===null?"—":fg(O.usd_micros)}),O===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):O===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(O.total_tokens??0).toLocaleString(),"(实际 ",(O.actual_tokens??0).toLocaleString()," / 估算 ",(O.estimated_tokens??0).toLocaleString(),",缓存 ",(O.cached_input_tokens??0).toLocaleString(),",无价 ",(O.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",O.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const j=Date.now()/1e3,w=r.last_heartbeat||r.updated_at||null,X=w?j-w:null;return c.jsxs(Jt,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",S,X!==null?" · 上次活动 "+Ag(X):""]}),X!==null&&X>180?c.jsx("div",{className:"muted",children:"超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)"}):c.jsx("div",{className:"muted",children:"首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加"})]})})():null,r.error_text?c.jsxs(Jt,{title:"失败原因",children:[c.jsx("div",{children:Cg(r.error_text)}),c.jsx("pre",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.error_text.slice(0,1e3)}),c.jsxs("div",{className:"muted",children:["完整错误文本仍在下方“操作与结果”里;模型 ",r.model||"默认"," · executor ",r.executor||""]})]}):null,c.jsx(Jt,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:Ne(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:Ne(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsx("dd",{children:r.executor||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:Ne(r.project_id)})]}),Y?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:Y})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:Ne(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(j=>j.name).join("、"),")"]})]}):null,r.parent_task_id?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",Ne(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Jt,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Jt,{title:"操作与结果",children:[c.jsx(Sg,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"协调子任务",children:c.jsx(jg,{})}),c.jsx(Jt,{title:"子代理链路",children:c.jsx(Tg,{})}),c.jsx(Jt,{title:"给其他任务发消息",children:c.jsx(Eg,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Jt,{title:"评审队列",children:c.jsx(xg,{})}),c.jsx(Jt,{title:"运行时审批队列",children:c.jsx(_g,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"浏览器远控",children:c.jsx(bg,{})})}):c.jsx(c.Fragment,{children:c.jsx(Jt,{title:"线程",children:c.jsx(Ng,{})})})]})}function zg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState([]),[p,H]=d.useState(null),[O,Y]=d.useState(!1),[j,w]=d.useState(""),[X,V]=d.useState(""),at=d.useCallback(async()=>{if(!i.keySaved){A([]);return}try{const J=await I(K.userKeys);A(J.keys||[]),V("")}catch(J){V(J instanceof Error?J.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{at()},[at]);const L=async()=>{w(""),V(""),Y(!1);try{const J=await I(K.userKeys,"POST",{label:f.trim()||"api-key"});H(J),S(""),w("新 key 已生成,只显示一次:复制后请妥善保存。"),await at()}catch(J){V(J instanceof Error?J.message:"生成 key 失败")}},vt=async()=>{if(p)try{await navigator.clipboard.writeText(p.api_key),Y(!0)}catch{V("复制失败:请手动选中上方 key 文本复制")}},bt=()=>{p&&(i.saveKeyInput(p.api_key),H(null),w("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},zt=async J=>{const Rt=(()=>{try{return sl().slice(-4)}catch{return""}})(),it=J.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":J.hint&&J.hint===Rt?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${J.label}」(后 4 位 ${J.hint||"无"})吗?${it}撤销不可恢复。`)){w(""),V("");try{await I(K.userKey(J.id),"DELETE"),w(`已撤销 key「${J.label}」。`),await at()}catch(Q){V(Q instanceof Error?Q.message:"撤销失败")}}};return c.jsxs(Jt,{id:"nav-key",title:"API Key",children:[c.jsxs(wt,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:J=>h(J.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(wt,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:J=>S(J.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void L(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void at(),children:"刷新列表"})]}),p&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",p.label,",后 4 位 ",p.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",readOnly:!0,value:p.api_key,onFocus:J=>J.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void vt(),children:O?"已复制":"复制"}),c.jsx("button",{onClick:bt,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>H(null),children:"关闭(不再显示)"})]})]}),j&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:j})}),X&&c.jsx("div",{children:c.jsx("span",{children:X})}),c.jsxs("div",{className:"api-key-list",children:[C.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),C.map(J=>c.jsxs("div",{children:[c.jsx("span",{children:J.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",J.hint||"无"," · 创建 ",Ya(J.created_at)," · 最近使用 ",J.last_used_at?Ya(J.last_used_at):"未使用过"]})," ",J.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Ya(J.revoked_at),")"]}):c.jsx("button",{onClick:()=>void zt(J),children:"撤销"})]},J.id))]})]})]})}function Rg(){const i=Zt();return c.jsxs(Jt,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(wt,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function Dg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState("");return c.jsxs(Jt,{id:"nav-projects",title:"项目",children:[c.jsxs(wt,{children:[c.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:p=>i.selectProject(p.target.value),children:[i.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(p=>c.jsxs("option",{value:p.id,children:[p.name," (",Ne(p.id),")"]},p.id))]}),c.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:p=>h(p.target.value)}),c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:p=>{S(p.target.value),p.target.value&&A(p.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(p=>c.jsx("option",{value:p.clone_url,children:p.repo_full_name},p.clone_url))]}),c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:C,onChange:p=>A(p.target.value)}),c.jsx("button",{onClick:async()=>{await i.createProject(r,C),h(""),A(""),S("")},children:"新建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]})}function Ug(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState("contributor"),C=i.projects.find(A=>A.id===i.projectId);return c.jsxs(Jt,{title:"项目成员",children:[C?c.jsxs("div",{className:"muted",children:[C.name," | ",C.id," | 仓库 ",C.repo_url||"未绑定"," | 我的角色 ",C.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:A=>h(A.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:A=>S(A.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,f),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.members.map(A=>{const p=(A.username||"")+(A.username?"("+String(A.user_id).slice(0,8)+")":String(A.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:p}),c.jsx("select",{"aria-label":"成员角色",value:A.role||"viewer",onChange:H=>void i.changeMemberRole(A.user_id,H.target.value),children:Wy.map(H=>c.jsx("option",{value:H,children:H},H))})]},A.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Hg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState("");return c.jsxs(Jt,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(wt,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.envs.map(p=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[p.name,"(",Ne(p.id),") / 分支 ",p.branch||"默认分支"," / 执行器 ",p.executor||"不限"," / 状态 ",p.status,p.status==="failed"&&p.error_text?" / 失败原因:"+p.error_text:""]}),p.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(p.id),children:"归档"})]},p.id))}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:p=>h(p.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:p=>S(p.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:C,onChange:p=>A(p.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,f,C),h(""),S("")},children:"新建环境"})]}),c.jsx("span",{className:"muted",children:i.envMsg})]})}function wg(){const i=Zt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(wt,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:f=>h(f.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(f=>c.jsx("option",{value:f.clone_url,children:f.repo_full_name},f.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function kg(){const i=Zt();return c.jsxs(Jt,{id:"nav-workers",title:"Workers",children:[c.jsx(wt,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):c.jsx(cu,{children:i.workers.map((r,h)=>{const f=r.running===void 0||r.running===null?"?":r.running,S=r.capacity===void 0||r.capacity===null?"?":r.capacity,C=Jh(r),A=r.alive!==void 0&&r.alive!==null?!!r.alive:C.age!==null&&C.age<=120,p=A?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:A?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[r.worker_id||r.id||"?"," / ",r.executor||""," / 占用 ",f,"/",S,p," / 最后心跳 ",C.text]})},h)})})]})}function Bg(){const i=Zt();return c.jsxs(Jt,{title:"健康与配额",children:[c.jsxs(wt,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function qg(){const i=Zt();return c.jsxs(Jt,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(wt,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(xl,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(xl,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(xl,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const f=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",f?" | "+f:""]},h)})})]})}function Yg(){const i=Zt(),[r,h]=d.useState("");return c.jsxs(Jt,{title:"MCP 操作",children:[c.jsxs(wt,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(wt,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:f=>h(f.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}function Gg(){const i=Zt(),[r,h]=d.useState(""),[f,S]=d.useState(""),[C,A]=d.useState(""),[p,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:O=>h(O.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:O=>S(O.target.value)}),c.jsxs(wt,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:C,onChange:O=>A(O.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:p,onChange:O=>H(O.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,C,p),h(""),S(""),A(""),H("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(xl,{empty:"(暂无自动化)",items:i.automations.map(O=>c.jsxs("span",{className:"row-main",children:["[",O.status,O.due?"|到期":"","] ",O.name,"|每 ",O.interval_seconds," 秒|下次 ",Ya(O.next_run_at),O.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.cancelAutomation(O.id),children:"取消"})]}):null]},O.id))})]})}function Qg(){var h,f,S,C;const i=Zt(),r=i.triage;return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((h=r.counts)==null?void 0:h.automations_due)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.automations_due||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:[A.name,"|每 ",A.interval_seconds," 秒"]},p))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((f=r.counts)==null?void 0:f.failed_tasks)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.failed_tasks||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:[Ne(A.id),"|",A.status,"|",String(A.prompt||"").slice(0,80)]},p))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((S=r.counts)==null?void 0:S.pending_followups)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.pending_followups||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:["任务 ",Ne(A.task_id),"|",String(A.body||"").slice(0,80)]},p))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((C=r.counts)==null?void 0:C.recent_runs)||0,")"]}),c.jsx(xl,{empty:"(无)",items:(r.recent_runs||[]).map((A,p)=>c.jsxs("span",{className:"row-main",children:[Ne(A.automation_id),"|",A.status,A.task_id?"|任务 "+Ne(A.task_id):"",A.task_status?"|"+A.task_status:""]},p))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function Xg(){return c.jsx(Jt,{id:"nav-automations",title:"自动化",children:c.jsx(Gg,{})})}function Lg(){const i=Zt();return c.jsxs(Jt,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(wt,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Qg,{})]})}const ho=["pending","in_progress","blocked","completed","cancelled"],Rc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},Vg=["low","medium","high"],Lh={low:"低",medium:"中",high:"高"};function Zg(i){return i?new Date(i*1e3).toLocaleString():""}function Kg(){const i=Zt(),[r,h]=d.useState([]),[f,S]=d.useState({}),[C,A]=d.useState(0),[p,H]=d.useState(""),[O,Y]=d.useState(""),[j,w]=d.useState(""),[X,V]=d.useState(""),[at,L]=d.useState(""),[vt,bt]=d.useState(""),[zt,J]=d.useState("medium"),[Rt,it]=d.useState(""),[Q,rt]=d.useState(""),Ut=d.useCallback(async()=>{if(!i.projectId){h([]),S({}),A(0);return}try{let B=K.projectTodos(i.projectId);const ot=new URLSearchParams;j&&ot.set("status",j),X.trim()&&ot.set("q",X.trim());const st=ot.toString();st&&(B+="?"+st);const q=await I(B);h(q.todos||[]),S(q.counts||{}),A(q.total||0),Y("")}catch(B){Y("待办加载失败:"+((B==null?void 0:B.message)||String(B)))}},[i.projectId,j,X]);d.useEffect(()=>{Ut()},[Ut]);const Qt=async()=>{if(!i.projectId){H("先选项目");return}if(!at.trim()){H("标题必填");return}H("");try{const B={title:at.trim(),description:vt,priority:zt};Rt.trim()&&(B.task_id=Rt.trim()),Q&&(B.due_at=Math.floor(new Date(Q).getTime()/1e3)),await I(K.projectTodos(i.projectId),"POST",B),L(""),bt(""),it(""),rt(""),H("已创建"),await Ut()}catch(B){H("创建失败:"+((B==null?void 0:B.message)||String(B)))}},Nt=async(B,ot)=>{if(i.projectId)try{await I(K.projectTodo(i.projectId,B.id),"PATCH",ot),await Ut()}catch(st){Y("待办更新失败:"+((st==null?void 0:st.message)||String(st)))}},kt=async B=>{if(i.projectId&&window.confirm("删除待办“"+B.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await I(K.projectTodo(i.projectId,B.id),"DELETE"),await Ut()}catch(ot){Y("待办删除失败:"+((ot==null?void 0:ot.message)||String(ot)))}};return c.jsxs(Jt,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+C+" 项 | "+ho.map(B=>Rc[B]+" "+(f[B]||0)).join(" | "):"先选项目"}),O?c.jsx("div",{className:"err",children:O}):null,c.jsxs(wt,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:j,onChange:B=>w(B.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),ho.map(B=>c.jsx("option",{value:B,children:Rc[B]},B))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:X,onChange:B=>V(B.target.value)}),c.jsx("button",{onClick:()=>void Ut(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:at,onChange:B=>L(B.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:vt,onChange:B=>bt(B.target.value)}),c.jsxs(wt,{children:[c.jsx("select",{"aria-label":"优先级",value:zt,onChange:B=>J(B.target.value),children:Vg.map(B=>c.jsxs("option",{value:B,children:["优先级:",Lh[B]]},B))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:Rt,onChange:B=>it(B.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:Q,onChange:B=>rt(B.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Qt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:p})]}),c.jsx(xl,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(B=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:B.title})," | ",c.jsx("span",{className:"st-"+B.status,children:Rc[B.status]||B.status})," | 优先级 "+(Lh[B.priority]||B.priority),B.task_id?" | 任务 "+Ne(B.task_id):"",B.due_at?" | 截止 "+Zg(B.due_at):"",B.completed_at?" | 完成于 "+new Date(B.completed_at*1e3).toLocaleString():"",B.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:B.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:B.status,onChange:ot=>void Nt(B,{status:ot.target.value}),children:ho.map(ot=>c.jsx("option",{value:ot,children:Rc[ot]},ot))})," ",c.jsx("button",{onClick:()=>void Nt(B,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void kt(B),children:"删除"})]},B.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const po=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["projects","项目与成员","管理"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],Jg=Object.fromEntries(po.map(([i,r])=>[i,r]));function Vh(){const i=window.location.hash.replace("#view-","");return po.some(([r])=>r===i)?i:"tasks"}function Fg({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),po.filter(([,,f])=>f===h).map(([f,S])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:S},f))]},h))}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(rg,{})]})]})}function Wg(){const i=Zt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function $g(){const i=sg();ug(p=>i.refreshAll(p),1e4),ig(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>Vh()),[f,S]=d.useState(!0),C=p=>{h(p);try{window.history.replaceState(null,"","#view-"+p)}catch{}};d.useEffect(()=>{const p=()=>h(Vh());return window.addEventListener("hashchange",p),()=>window.removeEventListener("hashchange",p)},[]);const A=()=>{C("tasks"),S(!0),setTimeout(()=>{var p;return(p=document.getElementById("sec-dispatch"))==null?void 0:p.scrollIntoView({behavior:"smooth"})},0)};return c.jsx(Kh.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(Fg,{view:r,setView:C}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:Jg[r]}),c.jsx(Wg,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(dg,{onNew:A})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>S(!f),children:f?"收起新建任务":"新建任务"})}),f?c.jsx("section",{id:"sec-dispatch",children:c.jsx(og,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(pg,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(Mg,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(Kg,{})}):r==="projects"?c.jsxs(c.Fragment,{children:[c.jsx(Dg,{}),c.jsx(Ug,{})]}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Hg,{}),c.jsx(wg,{}),c.jsx(kg,{}),c.jsx(Bg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(qg,{}),c.jsx(Yg,{}),c.jsx(Xg,{}),c.jsx(Lg,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Rg,{}),c.jsx(zg,{})]})})]})]})})}Ky.createRoot(document.getElementById("root")).render(c.jsx(qy.StrictMode,{children:c.jsx($g,{})})); diff --git a/cloud/server.py b/cloud/server.py index f1b5dbe..694805c 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -5383,9 +5383,20 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): current holder at least every 5s). The payload also carries the session url/title, so the console panel can track the header from the stream alone instead of - polling the state endpoint while watching. Ends the - stream when the state file goes stale, so a dead - session reads as a disconnect, not a frozen picture. + polling the state endpoint while watching. + + Stale state does NOT end the stream while the task is + still alive: between rounds (and across a worker + restart) the MCP is gone for a while and the state file + ages past the 30s staleness mark, which used to kill + every watch at the first round gap. Now a stale state + only switches the stream to waiting mode -- a heartbeat + with waiting:true every 5s, task status checked at the + same cadence -- and frames resume when the next round's + MCP starts polling again. The stream ends only when the + task reaches a terminal state (completed, merged, + failed), the task row disappears, or the state stays + stale for 10 minutes straight (abandoned watch). """ import time from cloud.playwright_remote import ( @@ -5406,23 +5417,62 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): paths = bridge_paths(worktree) last_frame = None last_heartbeat = 0.0 + last_holder = "" + stale_since = None + last_status_check = 0.0 + task_status = None try: while True: state, serr = read_state(worktree) + now = time.time() if serr is not None: - break + # No live session right now (round gap, + # worker restart, or a blocked drain loop). + # Wait it out while the task is still alive + # instead of dropping the watch. + if stale_since is None: + stale_since = now + if now - stale_since > 600: + break # abandoned watch + if now - last_status_check >= 5: + last_status_check = now + try: + row = self._fetch_task(task_id) + except Exception: # noqa: BLE001 - keep waiting + row = False # transient error, not deletion + if row is None: + break # task row gone + if row is not False: + task_status = row["status"] + if task_status in ( + "completed", "merged", "failed"): + break + if now - last_heartbeat >= 5: + last_heartbeat = now + payload = json.dumps( + {"image": "", + "holder": last_holder, + "waiting": True, + "task_status": task_status or "", + "ts": int(now)}, ensure_ascii=False) + self.wfile.write( + ("data: %s\n\n" % payload).encode("utf-8")) + self.wfile.flush() + time.sleep(0.02) + continue + stale_since = None try: with open(paths["frame"], "rb") as fh: frame = fh.read().decode("utf-8") except (OSError, UnicodeDecodeError): frame = "" - now = time.time() if frame != last_frame or now - last_heartbeat >= 5: last_frame = frame last_heartbeat = now + last_holder = effective_holder(worktree, state) payload = json.dumps( {"image": frame, - "holder": effective_holder(worktree, state), + "holder": last_holder, "url": state.get("url", ""), "title": state.get("title", ""), "ts": int(now)}, ensure_ascii=False) @@ -5432,6 +5482,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): time.sleep(0.02) except (BrokenPipeError, ConnectionResetError, OSError): pass + # An SSE stream has no Content-Length, so the client can + # only tell "stream ended" from the connection closing. + # HTTP/1.1 keep-alive would otherwise leave it hanging + # after a terminal break (completed/failed/cap) with no + # more bytes ever coming. Force the close on stream end. + self.close_connection = True def _task_chat_history(self, user_id, task_id): """GET /tasks/{id}/chat-history:真正的交替历史,一次性 diff --git a/cloud/tests/test_server_browser_proxy.py b/cloud/tests/test_server_browser_proxy.py index 4a31501..a252497 100644 --- a/cloud/tests/test_server_browser_proxy.py +++ b/cloud/tests/test_server_browser_proxy.py @@ -174,6 +174,51 @@ class BrowserProxyTestCase(unittest.TestCase): finally: conn.close() + def test_stream_waits_through_stale_state(self): + """A stale state must not drop a live task's watch. + + Regression for the 2026-10-30 demo watch: the old loop + broke on the first stale read, so every round gap (and + every worker restart) killed the stream. Now a stale + state switches to a waiting heartbeat while the task is + still alive, and only a terminal task status ends it. + """ + Path(self.wt, ".browser-remote-frame.jpg").write_bytes(b"JPEG1") + self._write_state("agent") + conn = http.client.HTTPConnection("127.0.0.1", self.port, + timeout=30) + try: + conn.request("GET", "/tasks/task-b1/browser/stream", + headers={"X-API-Key": self.owner_key}) + resp = conn.getresponse() + self.assertEqual(resp.status, 200) + # Each SSE event is two lines: data + blank separator. + line = resp.readline().decode("utf-8") + payload = json.loads(line[6:]) + self.assertEqual(payload["image"], "JPEG1") + self.assertEqual(resp.readline().decode("utf-8"), "\n") + # Round gap: state goes stale. The stream must stay + # open and emit a waiting heartbeat within ~5s. + self._write_state("agent", fresh=False) + line = resp.readline().decode("utf-8") + self.assertTrue(line.startswith("data: "), line) + payload = json.loads(line[6:]) + self.assertTrue(payload.get("waiting")) + self.assertEqual(payload.get("task_status"), "running") + self.assertEqual(resp.readline().decode("utf-8"), "\n") + # Terminal task status ends the stream. + db = sqlite3.connect(self.db_path) + try: + db.execute("UPDATE tasks SET status = 'completed'" + " WHERE id = 'task-b1'") + db.commit() + finally: + db.close() + line = resp.readline().decode("utf-8") + self.assertEqual(line, "") + finally: + conn.close() + if __name__ == "__main__": unittest.main()