server: 任务列表一次聚合回 total_tokens
_list_tasks 在取附件的同时用 _task_tokens_map 一次查 cost_records 按 task_id 求和(GROUP BY),一屏只多一条 查询,不按任务逐个调成本接口。无记录计 0、表不存在时 全计 0 且列表不失败;_task_to_dict 新增 total_tokens 参数,默认 None(非列表调用方不把“未算”写成 0)。 前端 TaskListSection 的 token 栏位此前已接好 total_tokens, 补上后列表行直接显示真数,不再显示 token —;前端注释 与占位文案同步更新。 新测试 test_task_list_tokens.py 3 项:HTTP 列表聚合 (actual+estimated 加总、无记录为 0)、缺表不炸、 _task_to_dict 默认 None。
This commit is contained in:
1 parent
65a875ada9
commit
9eedee5a3d
4 files changed
+168
-14
No files matched your search
@@ -21,16 +21,12 @@ import type { Task } from "../api/types";
|
||||
// 累计总时长,服务端需保留首次 started_at 或累加各轮时长,
|
||||
// 那是服务端字段缺口,不是前端能算出来的。
|
||||
//
|
||||
// token:列表接口(_task_to_dict)目前不回任何 token 字段,
|
||||
// 单任务成本在 GET /tasks/{id}/cost 里(total_tokens、
|
||||
// actual_tokens、estimated_tokens)。列表行不逐个调 /cost:
|
||||
// 一屏 N 个任务就是 N 个额外请求,会把列表刷新拖慢、还容易
|
||||
// 撞限流。所以这里只留栏位:服务端一旦在列表里补
|
||||
// total_tokens 就显示真数;分项只有 actual/estimated 时加总
|
||||
// 显示;都没有时显示“token —”,不编造、不估算。
|
||||
// 服务端补字段的最小改法:_task_to_dict 里给每行附
|
||||
// cost_records 按 task_id 求和的 total_tokens(或在列表查询
|
||||
// 里 LEFT JOIN 聚合一次),不需要新端点。
|
||||
// token:列表接口一次聚合回 total_tokens(服务端
|
||||
// _task_tokens_map:cost_records 按 task_id 求和,一屏只多
|
||||
// 一条查询)。列表行不逐个调 /cost:逐个调是一屏 N 个额外
|
||||
// 请求,会把列表刷新拖慢、还容易撞限流。分项只有
|
||||
// actual/estimated 时加总显示;都没有(非列表调用方、
|
||||
// total_tokens 为 null)时显示“token —”,不编造、不估算。
|
||||
function firstChar(t: Task): string {
|
||||
const p = (t.prompt || "").trim();
|
||||
if (p) return [...p][0];
|
||||
@@ -87,7 +83,7 @@ function tokenOf(t: Task): [string, string] {
|
||||
if (actual !== null || estimated !== null) {
|
||||
return [((actual || 0) + (estimated || 0)).toLocaleString() + " token", "token:actual + estimated 分项加总"];
|
||||
}
|
||||
return ["token —", "token:列表接口未返回 token 字段(服务端需在任务列表里补 total_tokens);单任务真数在任务详情的成本里可查,本行不逐个调成本接口、不编造"];
|
||||
return ["token —", "token:本行未带 token 字段;单任务真数在任务详情的成本里可查,本行不逐个调成本接口、不编造"];
|
||||
}
|
||||
|
||||
// GitHub issues-style task list: search on top, the project selector
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ Error generating stack: `+a.message+`
|
||||
`+D.result_text.slice(0,8e3)+`
|
||||
`),D.error_text&&(L+=`=== error ===
|
||||
`+D.error_text.slice(0,8e3)+`
|
||||
`),C(L||"(暂无 result/error 输出)")}catch{}dl(H.id),Bl(H.id),Ye(H.id),Vt(H.id),Hl(H),ol(H.id),ll(H.id),ml(H.id),ql(H.id)}},[dl,Bl,Ye,Vt,Hl,ol,ll,ml,ql]),gn=v.useCallback(async H=>{const D=ne.current;if(!D)return;const L={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(L[H]&&!window.confirm(L[H])))try{const J=await tt($.taskAction(D.id,H),"POST",{});x(H+" 已提交:"+JSON.stringify(J).slice(0,300)),r(),await Wl(D.id)}catch(J){x(H+" 失败:"+J.message)}},[Wl,r]),Ke=v.useCallback(async()=>{const H=ne.current;if(H)try{const D=await tt($.taskCost(H.id));b(JSON.stringify(D,null,2).slice(0,4e3))}catch(D){b("查成本失败:"+D.message)}},[]),Za=v.useCallback(async H=>{const D=ne.current;if(D){if(!H.trim()){I("分支消息必填");return}if(!su.includes(D.status)){I("仅终态任务可分支");return}try{const L=await tt($.taskFork(D.id),"POST",{message:H.trim()});I("分支任务已建:"+L.id.slice(0,8)),r()}catch(L){I("分支失败:"+L.message)}}},[r]),va=v.useCallback(async H=>{const D=ne.current;if(D)try{await tt($.taskReviews(D.id),"POST",H),le("评审已登记(requested)"),await Ye(D.id)}catch(L){le("登记评审需求失败:"+L.message)}},[Ye]),ou=v.useCallback(H=>{const D=ne.current;if(!D)return;const L=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");L!==null&&tt($.taskReviewFindings(D.id,H),"POST",{findings:L}).then(()=>void Ye(D.id)).catch(J=>le("写 findings 失败:"+J.message))},[Ye]),du=v.useCallback((H,D)=>{const L=ne.current;if(!L)return;const J=D==="approved"?"approve":"deny";tt($.taskApprovalDecision(L.id,H,J),"POST",{}).then(()=>{K("已记录"+(D==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Vt(L.id)}).catch(Ct=>K("决定失败:"+Ct.message))},[Vt]),$l=v.useCallback(async(H,D,L)=>{const J=ne.current;if(J){if(!D.trim()){k("目标任务 id 不能为空");return}if(!L.trim()){k("消息正文不能为空");return}try{const Ct=await tt($.taskMessagesTo(D.trim()),"POST",{from_task_id:J.id,body:L.trim(),mode:H});k("已发送("+Ct.delivery+")"),await ll(J.id)}catch(Ct){k("发送失败:"+Ct.message)}}},[ll]),Se=v.useCallback((H,D)=>{const L=ne.current;if(!L)return;if(!["completed","failed","interrupted"].includes(L.status)){Gt("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const J={};if(H==="rename"){if(!D.trim()){Gt("新线程名不能为空");return}J.name=D.trim()}tt($.taskThreadAction(L.id,H),"POST",J).then(Ct=>{Gt(H+" 已提交:"+JSON.stringify(Ct).slice(0,300)),ml(L.id),ql(L.id)}).catch(Ct=>Gt(H+" 失败:"+Ct.message))},[ml,ql]),hl=v.useCallback(async H=>{const D=ne.current;if(!D)return;const L=H.trim();if(!L){ae("消息不能为空");return}try{su.includes(D.status)&&D.status!=="merged"?(await tt($.taskContinue(D.id),"POST",{message:L}),ae("已重新排队,同一会话继续"),r(),await Wl(D.id)):["queued","running"].includes(D.status)?(await tt($.taskFollowups(D.id),"POST",{body:L,mode:"direct"}),ae("已直接发送"),await dl(D.id),await Bl(D.id)):ae("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(J){ae("发送失败:"+J.message)}},[Wl,r,dl,Bl]),be=v.useCallback(async()=>{const H=ne.current;if(!H){ie("先选一个任务");return}try{const D=await tt($.taskTrace(H.id)),L=new Blob([JSON.stringify({task_id:H.id,status:D.status,note:D.note||"",count:(D.entries||[]).length,entries:D.entries||[]},null,2)],{type:"application/json"}),J=document.createElement("a");J.href=URL.createObjectURL(L),J.download="trace-"+H.id.slice(0,8)+".json",document.body.appendChild(J),J.click(),J.remove(),setTimeout(()=>URL.revokeObjectURL(J.href),1e3),ie("已下载完整链路 "+(D.entries||[]).length+" 条")}catch(D){ie("下载失败:"+D.message)}},[]);return v.useEffect(()=>()=>qe(),[qe]),{currentTask:m,detailBody:_,actionMsg:N,actionReasons:G,costBox:Q,forkVisible:B,setForkVisible:lt,forkMsg:at,coordChildren:V,agents:Ot,agentsNote:nt,reviews:ee,reviewMsg:yt,reviewError:de,traceEntries:Ft,traceNote:tl,traceDone:Y,traceKind:et,setTraceKind:Tt,traceMsg:gt,followups:Oe,approvals:y,approvalMsg:F,peerMessages:pt,peerMsg:_t,threadText:ut,threadGateNote:Va,threadMsg:Le,threadList:zt,chatMsg:Fl,chatHistory:ha,showDetail:Wl,refreshDetailLists:yn,doAction:gn,taskCost:Ke,forkTask:Za,postReview:va,submitFindings:ou,decideApproval:du,postPeer:$l,threadAction:Se,sendChatMessage:hl,downloadTrace:be,refreshReviews:()=>void Ye(),refreshApprovals:()=>void Vt()}}function Wy(i){const[r,m]=v.useState([]),[o,_]=v.useState(""),[C,N]=v.useState(null),[x,G]=v.useState(""),U=v.useCallback(async lt=>{const at=lt!==void 0?lt:i();if(!at){m([]);return}try{const I=await tt($.projectAutomations(at));m(I.automations||[])}catch(I){_("自动化加载失败:"+I.message)}},[i]),Q=v.useCallback(async(lt,at,I,V)=>{cLine truncated
|
||||
`),C(L||"(暂无 result/error 输出)")}catch{}dl(H.id),Bl(H.id),Ye(H.id),Vt(H.id),Hl(H),ol(H.id),ll(H.id),ml(H.id),ql(H.id)}},[dl,Bl,Ye,Vt,Hl,ol,ll,ml,ql]),gn=v.useCallback(async H=>{const D=ne.current;if(!D)return;const L={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(L[H]&&!window.confirm(L[H])))try{const J=await tt($.taskAction(D.id,H),"POST",{});x(H+" 已提交:"+JSON.stringify(J).slice(0,300)),r(),await Wl(D.id)}catch(J){x(H+" 失败:"+J.message)}},[Wl,r]),Ke=v.useCallback(async()=>{const H=ne.current;if(H)try{const D=await tt($.taskCost(H.id));b(JSON.stringify(D,null,2).slice(0,4e3))}catch(D){b("查成本失败:"+D.message)}},[]),Za=v.useCallback(async H=>{const D=ne.current;if(D){if(!H.trim()){I("分支消息必填");return}if(!su.includes(D.status)){I("仅终态任务可分支");return}try{const L=await tt($.taskFork(D.id),"POST",{message:H.trim()});I("分支任务已建:"+L.id.slice(0,8)),r()}catch(L){I("分支失败:"+L.message)}}},[r]),va=v.useCallback(async H=>{const D=ne.current;if(D)try{await tt($.taskReviews(D.id),"POST",H),le("评审已登记(requested)"),await Ye(D.id)}catch(L){le("登记评审需求失败:"+L.message)}},[Ye]),ou=v.useCallback(H=>{const D=ne.current;if(!D)return;const L=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");L!==null&&tt($.taskReviewFindings(D.id,H),"POST",{findings:L}).then(()=>void Ye(D.id)).catch(J=>le("写 findings 失败:"+J.message))},[Ye]),du=v.useCallback((H,D)=>{const L=ne.current;if(!L)return;const J=D==="approved"?"approve":"deny";tt($.taskApprovalDecision(L.id,H,J),"POST",{}).then(()=>{K("已记录"+(D==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),Vt(L.id)}).catch(Ct=>K("决定失败:"+Ct.message))},[Vt]),$l=v.useCallback(async(H,D,L)=>{const J=ne.current;if(J){if(!D.trim()){k("目标任务 id 不能为空");return}if(!L.trim()){k("消息正文不能为空");return}try{const Ct=await tt($.taskMessagesTo(D.trim()),"POST",{from_task_id:J.id,body:L.trim(),mode:H});k("已发送("+Ct.delivery+")"),await ll(J.id)}catch(Ct){k("发送失败:"+Ct.message)}}},[ll]),Se=v.useCallback((H,D)=>{const L=ne.current;if(!L)return;if(!["completed","failed","interrupted"].includes(L.status)){Gt("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const J={};if(H==="rename"){if(!D.trim()){Gt("新线程名不能为空");return}J.name=D.trim()}tt($.taskThreadAction(L.id,H),"POST",J).then(Ct=>{Gt(H+" 已提交:"+JSON.stringify(Ct).slice(0,300)),ml(L.id),ql(L.id)}).catch(Ct=>Gt(H+" 失败:"+Ct.message))},[ml,ql]),hl=v.useCallback(async H=>{const D=ne.current;if(!D)return;const L=H.trim();if(!L){ae("消息不能为空");return}try{su.includes(D.status)&&D.status!=="merged"?(await tt($.taskContinue(D.id),"POST",{message:L}),ae("已重新排队,同一会话继续"),r(),await Wl(D.id)):["queued","running"].includes(D.status)?(await tt($.taskFollowups(D.id),"POST",{body:L,mode:"direct"}),ae("已直接发送"),await dl(D.id),await Bl(D.id)):ae("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(J){ae("发送失败:"+J.message)}},[Wl,r,dl,Bl]),be=v.useCallback(async()=>{const H=ne.current;if(!H){ie("先选一个任务");return}try{const D=await tt($.taskTrace(H.id)),L=new Blob([JSON.stringify({task_id:H.id,status:D.status,note:D.note||"",count:(D.entries||[]).length,entries:D.entries||[]},null,2)],{type:"application/json"}),J=document.createElement("a");J.href=URL.createObjectURL(L),J.download="trace-"+H.id.slice(0,8)+".json",document.body.appendChild(J),J.click(),J.remove(),setTimeout(()=>URL.revokeObjectURL(J.href),1e3),ie("已下载完整链路 "+(D.entries||[]).length+" 条")}catch(D){ie("下载失败:"+D.message)}},[]);return v.useEffect(()=>()=>qe(),[qe]),{currentTask:m,detailBody:_,actionMsg:N,actionReasons:G,costBox:Q,forkVisible:B,setForkVisible:lt,forkMsg:at,coordChildren:V,agents:Ot,agentsNote:nt,reviews:ee,reviewMsg:yt,reviewError:de,traceEntries:Ft,traceNote:tl,traceDone:Y,traceKind:et,setTraceKind:Tt,traceMsg:gt,followups:Oe,approvals:y,approvalMsg:F,peerMessages:pt,peerMsg:_t,threadText:ut,threadGateNote:Va,threadMsg:Le,threadList:zt,chatMsg:Fl,chatHistory:ha,showDetail:Wl,refreshDetailLists:yn,doAction:gn,taskCost:Ke,forkTask:Za,postReview:va,submitFindings:ou,decideApproval:du,postPeer:$l,threadAction:Se,sendChatMessage:hl,downloadTrace:be,refreshReviews:()=>void Ye(),refreshApprovals:()=>void Vt()}}function Wy(i){const[r,m]=v.useState([]),[o,_]=v.useState(""),[C,N]=v.useState(null),[x,G]=v.useState(""),U=v.useCallback(async lt=>{const at=lt!==void 0?lt:i();if(!at){m([]);return}try{const I=await tt($.projectAutomations(at));m(I.automations||[])}catch(I){_("自动化加载失败:"+I.message)}},[i]),Q=v.useCallback(async(lt,at,I,V)=>{cLine truncated
|
||||
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursor:pointer;padding-right:34px;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23656d76' stroke-width='1.5' stroke-linecap='round'/></svg>");background-repeat:no-repeat;background-position:right 12px center}select::-ms-expand{display:none}select:disabled,input:disabled{color:var(--muted);background:var(--subtle-bg);cursor:not-allowed}input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent)}input[type=file]{height:var(--ctl);display:inline-flex;align-items:center;padding:0 12px;border:1px dashed var(--line);border-radius:var(-Line truncated
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+38
-2
@@ -1233,6 +1233,37 @@ def _attachments_map(conn, task_ids):
|
||||
return out
|
||||
|
||||
|
||||
def _task_tokens_map(conn, task_ids):
|
||||
"""一批任务的 cost_records token 合计:{task_id: total_tokens}。
|
||||
|
||||
任务列表接口用一次查询取全量,不按任务逐条调成本接口
|
||||
(一屏 N 个任务就是 N 个请求,会拖慢列表刷新、还容易撞
|
||||
限流)。无成本记录的任务计 0(不是缺失);cost_records
|
||||
表还不存在时(全新库、CostStore 未建过表)同样全计 0,
|
||||
列表绝不因此失败——0 在这里就是“未记录到任何 token”,
|
||||
与 _metrics 的缺表按空统计口径一致。
|
||||
"""
|
||||
out = {tid: 0 for tid in task_ids}
|
||||
if not task_ids:
|
||||
return out
|
||||
placeholders = ", ".join("?" * len(task_ids))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT task_id, COALESCE(SUM(total_tokens),0) AS t"
|
||||
" FROM cost_records WHERE task_id IN (%s)"
|
||||
" GROUP BY task_id" % placeholders,
|
||||
tuple(task_ids),
|
||||
).fetchall()
|
||||
except (sqlite3.OperationalError, _PgOperationalError):
|
||||
return out
|
||||
for row in rows:
|
||||
tid = row[0] if isinstance(row, tuple) else row["task_id"]
|
||||
if tid in out:
|
||||
out[tid] = int(row[1] if isinstance(row, tuple)
|
||||
else row["t"] or 0)
|
||||
return out
|
||||
|
||||
|
||||
def _ensure_task_checkpoints_table(conn):
|
||||
"""幂等建断点表(P0 三件其一):server init_db(PG 与 SQLite
|
||||
两支)与 worker 迁移兜底共用。DOUBLE PRECISION 在两后端都可用。
|
||||
@@ -1663,7 +1694,7 @@ def _row_get(row, name):
|
||||
return None
|
||||
|
||||
|
||||
def _task_to_dict(row, attachments=None):
|
||||
def _task_to_dict(row, attachments=None, total_tokens=None):
|
||||
depends_on = row["depends_on"]
|
||||
try:
|
||||
depends_on = json.loads(depends_on) if depends_on else []
|
||||
@@ -1719,6 +1750,9 @@ def _task_to_dict(row, attachments=None):
|
||||
"interrupted_reason": _row_get(row, "interrupted_reason"),
|
||||
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
|
||||
"no_time_limit": _row_get(row, "no_time_limit") or 0,
|
||||
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
|
||||
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
|
||||
|
||||
@@ -4390,10 +4424,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
amap = _attachments_map(conn, [r["id"] for r in rows])
|
||||
tmap = _task_tokens_map(conn, [r["id"] for r in rows])
|
||||
finally:
|
||||
conn.close()
|
||||
self._send_json(200, {"tasks": [
|
||||
_task_to_dict(r, amap[r["id"]]) for r in rows]})
|
||||
_task_to_dict(r, amap[r["id"]], tmap.get(r["id"], 0))
|
||||
for r in rows]})
|
||||
|
||||
def _fetch_task(self, task_id):
|
||||
conn = self._db()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""任务列表 total_tokens:一次聚合,不逐个调成本接口。
|
||||
|
||||
用真实 HTTP 服务 + 临时 SQLite:建两个任务,给其中一个记
|
||||
两条成本记录(actual + estimated),列表应一次回两行的
|
||||
total_tokens(有记录的加总、无记录的 0),且不回 404/500;
|
||||
_task_tokens_map 在 cost_records 表不存在时也不炸列表。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.cost import CostStore
|
||||
from cloud.server import (
|
||||
_task_to_dict, _task_tokens_map, create_server)
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.cost import CostStore
|
||||
from cloud.server import (
|
||||
_task_to_dict, _task_tokens_map, create_server)
|
||||
|
||||
|
||||
class ListTokensTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
try:
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return resp.status, parsed
|
||||
|
||||
def test_list_returns_aggregated_total_tokens(self):
|
||||
pid = self.project["id"]
|
||||
_, t1 = self._req("POST", "/tasks",
|
||||
{"project_id": pid, "prompt": "有成本"},
|
||||
self.owner_key)
|
||||
_, t2 = self._req("POST", "/tasks",
|
||||
{"project_id": pid, "prompt": "无成本"},
|
||||
self.owner_key)
|
||||
store = CostStore(self.db_path)
|
||||
try:
|
||||
store.record(t1["id"], pid, "m", input_tokens=100,
|
||||
output_tokens=50, source="actual")
|
||||
store.record(t1["id"], pid, "m", input_tokens=20,
|
||||
output_tokens=0, source="estimated")
|
||||
finally:
|
||||
store._conn.close()
|
||||
status, body = self._req(
|
||||
"GET", "/tasks?project_id=%s" % pid, key=self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
by_id = {t["id"]: t for t in body["tasks"]}
|
||||
self.assertEqual(by_id[t1["id"]]["total_tokens"], 170)
|
||||
self.assertEqual(by_id[t2["id"]]["total_tokens"], 0)
|
||||
|
||||
def test_tokens_map_missing_table_is_zero_not_error(self):
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
self.assertEqual(
|
||||
_task_tokens_map(conn, ["t1", "t2"]),
|
||||
{"t1": 0, "t2": 0})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_task_to_dict_default_total_tokens_is_none(self):
|
||||
row = {"id": "t1", "project_id": "p1", "prompt": "p",
|
||||
"status": "completed", "idempotency_key": None,
|
||||
"depends_on": "[]", "priority": 0, "created_by": "u",
|
||||
"created_at": 1.0, "updated_at": 2.0,
|
||||
"last_heartbeat": None}
|
||||
self.assertIsNone(_task_to_dict(row, [])["total_tokens"])
|
||||
self.assertEqual(
|
||||
_task_to_dict(row, [], 42)["total_tokens"], 42)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user