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:
host committed 2026-10-29 18:27:53 +08:00
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
View File
@@ -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
View File
@@ -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()
+122
View File
@@ -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()