codebee 0.1.95 → 0.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +733 -720
  2. package/LICENSE +21 -21
  3. package/README.md +516 -509
  4. package/app/core/acceptance.py +100 -100
  5. package/app/core/agent_context.py +118 -0
  6. package/app/core/aiflavor.py +197 -197
  7. package/app/core/analytics.py +38 -38
  8. package/app/core/artifacts.py +148 -148
  9. package/app/core/assets.py +110 -110
  10. package/app/core/attachments.py +623 -623
  11. package/app/core/audit.py +30 -30
  12. package/app/core/backup.py +470 -470
  13. package/app/core/beekeeper.py +183 -183
  14. package/app/core/benchstore.py +102 -95
  15. package/app/core/board.py +246 -209
  16. package/app/core/bookmeta.py +1036 -1036
  17. package/app/core/bookmeta_catalog.py +180 -180
  18. package/app/core/branching.py +137 -137
  19. package/app/core/builtin_agent.py +2055 -1680
  20. package/app/core/capability.py +144 -144
  21. package/app/core/chaptersafe.py +32 -32
  22. package/app/core/checkpoints.py +648 -0
  23. package/app/core/cleanup.py +303 -303
  24. package/app/core/compaction.py +201 -201
  25. package/app/core/contracts.py +255 -255
  26. package/app/core/covergen.py +356 -356
  27. package/app/core/defectretro.py +43 -43
  28. package/app/core/diagnostics.py +114 -114
  29. package/app/core/dispatch.py +198 -191
  30. package/app/core/dispatch_log.py +128 -128
  31. package/app/core/env_scrub.py +85 -85
  32. package/app/core/errorlog.py +137 -137
  33. package/app/core/eval_matrix.py +82 -0
  34. package/app/core/evalbench.py +184 -91
  35. package/app/core/evaluation.py +174 -174
  36. package/app/core/failure_notify.py +290 -290
  37. package/app/core/flow_graph.py +33 -0
  38. package/app/core/goal_service.py +167 -167
  39. package/app/core/health.py +389 -389
  40. package/app/core/history.py +38 -38
  41. package/app/core/hooks.py +162 -162
  42. package/app/core/jobs.py +25 -4
  43. package/app/core/knowledge_pipeline.py +86 -0
  44. package/app/core/manager.py +2232 -2232
  45. package/app/core/mcp_client.py +200 -29
  46. package/app/core/mocks.py +64 -64
  47. package/app/core/notify.py +201 -201
  48. package/app/core/novel_quality.py +181 -189
  49. package/app/core/openapi.py +177 -154
  50. package/app/core/operations.py +228 -228
  51. package/app/core/paihang.py +145 -145
  52. package/app/core/pipeline.py +319 -59
  53. package/app/core/planner.py +17 -11
  54. package/app/core/policy.py +84 -0
  55. package/app/core/portguard.py +111 -111
  56. package/app/core/presence.py +77 -77
  57. package/app/core/preview.py +293 -293
  58. package/app/core/project_memory.py +139 -0
  59. package/app/core/prompt_guard.py +78 -78
  60. package/app/core/publish/__init__.py +7 -7
  61. package/app/core/publish/auto.py +106 -22
  62. package/app/core/publish/browser.py +694 -655
  63. package/app/core/publish/chaprows.py +138 -0
  64. package/app/core/publish/fanqie.py +212 -165
  65. package/app/core/publish/flow.py +832 -640
  66. package/app/core/publish/flows-fanqie-calibrated.json +361 -336
  67. package/app/core/publish/ledger.py +51 -2
  68. package/app/core/publish/manager.py +1150 -1118
  69. package/app/core/publish/qimao.py +176 -176
  70. package/app/core/publish/volumes.py +108 -0
  71. package/app/core/publish/ws.py +139 -139
  72. package/app/core/quality_gate.py +194 -194
  73. package/app/core/redact.py +38 -38
  74. package/app/core/repeat_guard.py +129 -129
  75. package/app/core/retrieval.py +137 -137
  76. package/app/core/router.py +398 -398
  77. package/app/core/runner.py +2239 -2238
  78. package/app/core/selfupdate.py +384 -384
  79. package/app/core/session_log.py +161 -161
  80. package/app/core/sessions.py +312 -312
  81. package/app/core/settings.py +33 -5
  82. package/app/core/settings_schema.py +282 -271
  83. package/app/core/share_page.py +114 -114
  84. package/app/core/skill_scan.py +139 -139
  85. package/app/core/skillpacks/fanqie-novel.md +8 -6
  86. package/app/core/skillpacks/market/character-bible.md +66 -66
  87. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  88. package/app/core/skillpacks/market/git-workflow.md +57 -57
  89. package/app/core/skillpacks/market/release-notes.md +72 -72
  90. package/app/core/skillpacks/market/weekly-report.md +71 -71
  91. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  92. package/app/core/skillpacks/qimao-signing.md +14 -11
  93. package/app/core/skills.py +32 -21
  94. package/app/core/step_runner.py +132 -132
  95. package/app/core/store.py +24 -0
  96. package/app/core/story_tracking.py +275 -275
  97. package/app/core/task_compile.py +199 -199
  98. package/app/core/telemetry.py +292 -292
  99. package/app/core/tlsctx.py +64 -64
  100. package/app/core/token_meter.py +147 -147
  101. package/app/core/tool_outputs.py +107 -0
  102. package/app/core/tracing.py +207 -144
  103. package/app/core/upstream.py +41 -41
  104. package/app/core/usage.py +1038 -1038
  105. package/app/core/volumes.py +389 -389
  106. package/app/core/webhooks.py +89 -0
  107. package/app/core/wxdigest.py +865 -858
  108. package/app/core/zentao.py +2692 -2692
  109. package/app/main.py +179 -10
  110. package/app/mcp_server.py +244 -244
  111. package/app/pick_dialog.py +110 -110
  112. package/app/ui/app.js +563 -21
  113. package/app/ui/artdiff.js +74 -74
  114. package/app/ui/assets/community-qr.png +0 -0
  115. package/app/ui/assets/donate-alipay.jpg +0 -0
  116. package/app/ui/assets/donate-qr.jpg +0 -0
  117. package/app/ui/assets/workbench-reference.png +0 -0
  118. package/app/ui/board.css +451 -666
  119. package/app/ui/board.html +165 -162
  120. package/app/ui/board.js +509 -305
  121. package/app/ui/hive3d.js +242 -0
  122. package/app/ui/i18n.js +125 -9
  123. package/app/ui/icons/bee.svg +79 -79
  124. package/app/ui/index.html +60 -3
  125. package/app/ui/manifest.json +16 -16
  126. package/app/ui/qrcode.js +2297 -2297
  127. package/app/ui/style.css +189 -9
  128. package/bin/tutti.js +147 -147
  129. package/package.json +49 -48
  130. package/tools/wx_reader.py +261 -261
  131. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  132. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  133. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  134. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  135. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  136. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  137. package/app/.playwright-cli/run-detail-after.png +0 -0
  138. package/app/output/_createbook_body.json +0 -1
  139. package/app/output/_probe_fq_click.py +0 -50
  140. package/app/output/_probe_fq_form.py +0 -42
  141. package/app/output/_probe_fq_modal.py +0 -55
  142. package/app/output/_probe_fq_points.py +0 -71
  143. package/app/output/_probe_fq_submit.py +0 -34
  144. package/app/output/_probe_fq_watch.py +0 -62
package/app/core/board.py CHANGED
@@ -1,209 +1,246 @@
1
- # -*- coding: utf-8 -*-
2
- """任务驾驶舱(/board 大屏)的轻量聚合数据源。
3
-
4
- 大屏 4 秒轮询:不能拉 /api/state 全量(MB 级),也不能每次全量扫 usage
5
- 台账(summary 全历史读入),所以这里只取大屏要看的字段,并把 usage 聚合
6
- 缓存 8 秒。只读 store/usage/jobs/health,不落新盘。
7
- """
8
- from __future__ import annotations
9
-
10
- import time
11
-
12
- _CACHE = {"ts": 0.0, "data": None}
13
- _TTL = 8.0
14
-
15
- _TAIL_MAX = 150 # 跑马灯尾行截断
16
- _TAIL_LINES = 6 # 实时流窗口行数
17
- _TAIL_LINE_CHARS = 56 # 实时流单行截断(卡内 11px 等宽不换行的安全宽度)
18
- _RECENT_LIMIT = 10 # 最近完成/失败条数
19
- _RUN_WINDOW = 300 # 今日计数扫描的运行窗口(id 字典序=时间序)
20
-
21
-
22
- def payload():
23
- now = time.time()
24
- if _CACHE["data"] is not None and now - _CACHE["ts"] < _TTL:
25
- return _CACHE["data"]
26
- data = _build()
27
- _CACHE["ts"] = now
28
- _CACHE["data"] = data
29
- return data
30
-
31
-
32
- def _tail(step):
33
- """步骤实时流(正文优先,其次思考)的最后一行,给大屏跑马灯。"""
34
- for key in ("stream", "thinking"):
35
- v = step.get(key)
36
- if isinstance(v, str) and v.strip():
37
- lines = [ln.strip() for ln in v.strip().splitlines() if ln.strip()]
38
- if lines:
39
- return lines[-1][:_TAIL_MAX]
40
- return ""
41
-
42
-
43
- def _live_block(step):
44
- """步骤实时流窗口:尾部多行(正文优先,其次思考),供大屏看它在想什么。
45
-
46
- 返回 (来源标签 code, 文本);标签是 code(output/thinking),由前端 i18n
47
- 翻译——大屏要做多语言,后端不下发中文。都没有返回 ("", "")。
48
- """
49
- for key, code in (("stream", "output"), ("thinking", "thinking")):
50
- v = step.get(key)
51
- if not (isinstance(v, str) and v.strip()):
52
- continue
53
- lines = [ln.strip()[:_TAIL_LINE_CHARS] for ln in v.strip().splitlines()]
54
- lines = [ln for ln in lines if ln]
55
- if lines:
56
- return code, "\n".join(lines[-_TAIL_LINES:])
57
- return "", ""
58
-
59
-
60
- def _model_provider_map():
61
- """模型名 → 厂商名(enabled 优先,同模型多厂商取 priority 最靠前的)。"""
62
- try:
63
- from . import modelhub
64
- rows = modelhub.models_view()
65
- except Exception:
66
- return {}
67
- best = {}
68
- for r in rows:
69
- name = r.get("name")
70
- if not name or not r.get("enabled", False):
71
- continue
72
- pri = r.get("priority", 0)
73
- cur = best.get(name)
74
- if cur is None or pri < cur[0]:
75
- best[name] = (pri, r.get("provider_name") or r.get("provider_id") or "")
76
- return {k: v[1] for k, v in best.items()}
77
-
78
-
79
- def _parse_ts(s):
80
- try:
81
- return time.mktime(time.strptime(str(s), "%Y-%m-%d %H:%M:%S"))
82
- except (ValueError, TypeError):
83
- return 0.0
84
-
85
-
86
- def _duration_s(run):
87
- """运行耗时(秒):ended-started,缺 started 用 created 兜底。"""
88
- end = _parse_ts(run.get("ended_at"))
89
- start = _parse_ts(run.get("started_at")) or _parse_ts(run.get("created_at"))
90
- if end > 0 and start > 0 and end >= start:
91
- return int(end - start)
92
- return None
93
-
94
-
95
- def _step_view(steps, prov_map):
96
- """进度概要:完成数 + 当前运行中步骤(含模型/厂商/实时流)。"""
97
- done = sum(1 for s in steps if s.get("status") == "done")
98
- cur = next((s for s in steps if s.get("status") == "running"), None)
99
- cur_v = None
100
- if cur:
101
- live_label, live_text = _live_block(cur)
102
- model = str(cur.get("model") or "")[:60]
103
- cur_v = {"n": cur.get("n"), "role": cur.get("role") or "",
104
- "agent": cur.get("agent_label") or cur.get("agent") or "",
105
- "summary": (cur.get("summary") or "")[:80],
106
- "tail": _tail(cur),
107
- "model": model, "provider": prov_map.get(model, "") if model else "",
108
- "live_label": live_label, "live_text": live_text}
109
- return done, len(steps), cur_v
110
-
111
-
112
- def _build():
113
- from . import flows, health, jobs, store, usage
114
-
115
- flow_map = {}
116
- try:
117
- for f in flows.list_flows():
118
- flow_map[f.get("id")] = f
119
- except Exception:
120
- pass
121
-
122
- prov_map = _model_provider_map()
123
- today = time.strftime("%Y-%m-%d")
124
- tasks = store.list_tasks(200, archived=False)
125
- latest = store.latest_run_by_task()
126
-
127
- running, queued = [], []
128
- for t in tasks:
129
- tid = t.get("id")
130
- run = latest.get(tid)
131
- status = t.get("status")
132
- if status == "running" and run and run.get("status") == "running":
133
- done, total, cur = _step_view(run.get("steps") or [], prov_map)
134
- running.append({
135
- "task_id": tid, "run_id": run.get("id"),
136
- "title": t.get("title") or "", "goal": (t.get("goal") or "")[:120],
137
- "type": t.get("type") or "",
138
- "type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
139
- "status": status,
140
- "started_at": run.get("started_at") or run.get("created_at") or "",
141
- "steps_done": done, "steps_total": total,
142
- "current": cur,
143
- "run_tokens": run.get("tokens") or 0,
144
- "run_cost_usd": round(float(run.get("cost_usd") or 0.0), 4),
145
- "estimate_s": run.get("estimated_duration_s"),
146
- })
147
- elif status == "queued":
148
- queued.append({
149
- "task_id": tid, "run_id": (run or {}).get("id"),
150
- "title": t.get("title") or "",
151
- "type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
152
- "created_at": (run or {}).get("created_at") or "",
153
- })
154
- running.sort(key=lambda c: _parse_ts(c["started_at"]))
155
- queued.sort(key=lambda c: _parse_ts(c["created_at"]))
156
-
157
- runs = store.list_runs(_RUN_WINDOW)
158
- today_done = today_failed = 0
159
- recent = []
160
- for r in runs:
161
- st = r.get("status")
162
- ended = str(r.get("ended_at") or "")
163
- if st in ("done", "failed", "cancelled") and ended.startswith(today):
164
- if st == "done":
165
- today_done += 1
166
- elif st == "failed":
167
- today_failed += 1
168
- if len(recent) < _RECENT_LIMIT and st in ("done", "failed", "cancelled") and ended:
169
- recent.append({
170
- "run_id": r.get("id"), "task_id": r.get("task_id"),
171
- "title": r.get("title") or "", "status": st,
172
- "ended_at": ended, "duration_s": _duration_s(r),
173
- "error": (r.get("error") or "")[:80],
174
- })
175
-
176
- trend, models, usage_today = [], [], {"tokens": 0, "calls": 0, "cost_usd": 0.0}
177
- try:
178
- s = usage.summary(days=7)
179
- trend = [{"day": d.get("day"), "tokens": d.get("tokens") or 0}
180
- for d in (s.get("by_day") or [])]
181
- for row in (s.get("by_model") or [])[:5]:
182
- if not row.get("tokens"):
183
- continue
184
- models.append({"model": row.get("key") or "?",
185
- "tokens": row.get("tokens") or 0,
186
- "calls": row.get("calls") or 0})
187
- for d in s.get("by_day") or []:
188
- if str(d.get("day")) == today:
189
- usage_today = {"tokens": d.get("tokens") or 0,
190
- "calls": d.get("calls") or 0,
191
- "cost_usd": round(float(d.get("cost_usd") or 0.0), 3)}
192
- break
193
- except Exception:
194
- pass
195
-
196
- return {
197
- "now": time.strftime("%Y-%m-%d %H:%M:%S"),
198
- "today": today,
199
- "running": running,
200
- "queued": queued,
201
- "today_done": today_done,
202
- "today_failed": today_failed,
203
- "recent": recent,
204
- "trend": trend,
205
- "models": models,
206
- "usage_today": usage_today,
207
- "pool": jobs.workers_info(),
208
- "health": health.snapshot().get("providers") or [],
209
- }
1
+ # -*- coding: utf-8 -*-
2
+ """任务驾驶舱(/board 大屏)的轻量聚合数据源。
3
+
4
+ 大屏 4 秒轮询:不能拉 /api/state 全量(MB 级),也不能每次全量扫 usage
5
+ 台账(summary 全历史读入),所以这里只取大屏要看的字段,并把 usage 聚合
6
+ 缓存 8 秒。只读 store/usage/jobs/health,不落新盘。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ _CACHE = {"ts": 0.0, "data": None}
13
+ _TTL = 8.0
14
+
15
+ _TAIL_MAX = 150 # 跑马灯尾行截断
16
+ _TAIL_LINES = 14 # 实时流窗口行数(单任务卡的终端约 20 行,留余量)
17
+ _TAIL_LINE_CHARS = 110 # 实时流单行截断;卡内 pre-wrap,超宽会折行而不是切掉
18
+ _RECENT_LIMIT = 10 # 最近完成/失败条数
19
+ _RUN_WINDOW = 300 # 今日计数扫描的运行窗口(id 字典序=时间序)
20
+
21
+
22
+ def payload():
23
+ now = time.time()
24
+ if _CACHE["data"] is not None and now - _CACHE["ts"] < _TTL:
25
+ return _CACHE["data"]
26
+ data = _build()
27
+ _CACHE["ts"] = now
28
+ _CACHE["data"] = data
29
+ return data
30
+
31
+
32
+ def run_detail(run_id):
33
+ """单条运行的步骤链,供大屏下钻抽屉;不进 8 秒缓存(点开即要最新)。
34
+
35
+ 只给步骤概要,日志正文不在这里搬——前端选中某一步时走现成的
36
+ /api/runs/<id>/log?step=<log>&pretty=1,那份已有目录穿越防护和 tail 夹取。
37
+ """
38
+ from . import store
39
+
40
+ run = store.get_run(str(run_id or ""))
41
+ if not run:
42
+ return None
43
+ steps = []
44
+ for s in (run.get("steps") or []):
45
+ steps.append({
46
+ "n": s.get("n"), "role": s.get("role") or "",
47
+ "agent": s.get("agent") or "",
48
+ "agent_label": s.get("agent_label") or s.get("agent") or "",
49
+ "status": s.get("status") or "",
50
+ "started_at": s.get("started_at") or "", "ended_at": s.get("ended_at") or "",
51
+ "duration_s": s.get("duration_s"),
52
+ "summary": (s.get("summary") or "")[:120],
53
+ "log": s.get("log") or "",
54
+ "tokens": s.get("tokens") or 0,
55
+ "cost_usd": round(float(s.get("cost_usd") or 0.0), 4),
56
+ "exit_code": s.get("exit_code"),
57
+ "error": (s.get("error") or "")[:160],
58
+ })
59
+ return {
60
+ "run_id": run.get("id"), "task_id": run.get("task_id") or "",
61
+ "title": run.get("title") or "", "status": run.get("status") or "",
62
+ "started_at": run.get("started_at") or run.get("created_at") or "",
63
+ "ended_at": run.get("ended_at") or "",
64
+ "error": (run.get("error") or "")[:200],
65
+ "steps": steps,
66
+ }
67
+
68
+
69
+ def _tail(step):
70
+ """步骤实时流(正文优先,其次思考)的最后一行,给大屏跑马灯。"""
71
+ for key in ("stream", "thinking"):
72
+ v = step.get(key)
73
+ if isinstance(v, str) and v.strip():
74
+ lines = [ln.strip() for ln in v.strip().splitlines() if ln.strip()]
75
+ if lines:
76
+ return lines[-1][:_TAIL_MAX]
77
+ return ""
78
+
79
+
80
+ def _live_block(step):
81
+ """步骤实时流窗口:尾部多行(正文优先,其次思考),供大屏看它在想什么。
82
+
83
+ 返回 (来源标签 code, 文本);标签是 code(output/thinking),由前端 i18n
84
+ 翻译——大屏要做多语言,后端不下发中文。都没有返回 ("", "")。
85
+ """
86
+ for key, code in (("stream", "output"), ("thinking", "thinking")):
87
+ v = step.get(key)
88
+ if not (isinstance(v, str) and v.strip()):
89
+ continue
90
+ lines = [ln.strip()[:_TAIL_LINE_CHARS] for ln in v.strip().splitlines()]
91
+ lines = [ln for ln in lines if ln]
92
+ if lines:
93
+ return code, "\n".join(lines[-_TAIL_LINES:])
94
+ return "", ""
95
+
96
+
97
+ def _model_provider_map():
98
+ """模型名 → 厂商名(enabled 优先,同模型多厂商取 priority 最靠前的)。"""
99
+ try:
100
+ from . import modelhub
101
+ rows = modelhub.models_view()
102
+ except Exception:
103
+ return {}
104
+ best = {}
105
+ for r in rows:
106
+ name = r.get("name")
107
+ if not name or not r.get("enabled", False):
108
+ continue
109
+ pri = r.get("priority", 0)
110
+ cur = best.get(name)
111
+ if cur is None or pri < cur[0]:
112
+ best[name] = (pri, r.get("provider_name") or r.get("provider_id") or "")
113
+ return {k: v[1] for k, v in best.items()}
114
+
115
+
116
+ def _parse_ts(s):
117
+ try:
118
+ return time.mktime(time.strptime(str(s), "%Y-%m-%d %H:%M:%S"))
119
+ except (ValueError, TypeError):
120
+ return 0.0
121
+
122
+
123
+ def _duration_s(run):
124
+ """运行耗时(秒):ended-started,缺 started 用 created 兜底。"""
125
+ end = _parse_ts(run.get("ended_at"))
126
+ start = _parse_ts(run.get("started_at")) or _parse_ts(run.get("created_at"))
127
+ if end > 0 and start > 0 and end >= start:
128
+ return int(end - start)
129
+ return None
130
+
131
+
132
+ def _step_view(steps, prov_map):
133
+ """进度概要:完成数 + 当前运行中步骤(含模型/厂商/实时流)。"""
134
+ done = sum(1 for s in steps if s.get("status") == "done")
135
+ cur = next((s for s in steps if s.get("status") == "running"), None)
136
+ cur_v = None
137
+ if cur:
138
+ live_label, live_text = _live_block(cur)
139
+ model = str(cur.get("model") or "")[:60]
140
+ cur_v = {"n": cur.get("n"), "role": cur.get("role") or "",
141
+ "agent": cur.get("agent_label") or cur.get("agent") or "",
142
+ "summary": (cur.get("summary") or "")[:80],
143
+ "tail": _tail(cur),
144
+ "model": model, "provider": prov_map.get(model, "") if model else "",
145
+ "live_label": live_label, "live_text": live_text}
146
+ return done, len(steps), cur_v
147
+
148
+
149
+ def _build():
150
+ from . import flows, health, jobs, store, usage
151
+
152
+ flow_map = {}
153
+ try:
154
+ for f in flows.list_flows():
155
+ flow_map[f.get("id")] = f
156
+ except Exception:
157
+ pass
158
+
159
+ prov_map = _model_provider_map()
160
+ today = time.strftime("%Y-%m-%d")
161
+ tasks = store.list_tasks(200, archived=False)
162
+ latest = store.latest_run_by_task()
163
+
164
+ running, queued = [], []
165
+ for t in tasks:
166
+ tid = t.get("id")
167
+ run = latest.get(tid)
168
+ status = t.get("status")
169
+ if status == "running" and run and run.get("status") == "running":
170
+ done, total, cur = _step_view(run.get("steps") or [], prov_map)
171
+ running.append({
172
+ "task_id": tid, "run_id": run.get("id"),
173
+ "title": t.get("title") or "", "goal": (t.get("goal") or "")[:120],
174
+ "type": t.get("type") or "",
175
+ "type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
176
+ "status": status,
177
+ "started_at": run.get("started_at") or run.get("created_at") or "",
178
+ "steps_done": done, "steps_total": total,
179
+ "current": cur,
180
+ "run_tokens": run.get("tokens") or 0,
181
+ "run_cost_usd": round(float(run.get("cost_usd") or 0.0), 4),
182
+ "estimate_s": run.get("estimated_duration_s"),
183
+ })
184
+ elif status == "queued":
185
+ queued.append({
186
+ "task_id": tid, "run_id": (run or {}).get("id"),
187
+ "title": t.get("title") or "",
188
+ "type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
189
+ "created_at": (run or {}).get("created_at") or "",
190
+ })
191
+ running.sort(key=lambda c: _parse_ts(c["started_at"]))
192
+ queued.sort(key=lambda c: _parse_ts(c["created_at"]))
193
+
194
+ runs = store.list_runs(_RUN_WINDOW)
195
+ today_done = today_failed = 0
196
+ recent = []
197
+ for r in runs:
198
+ st = r.get("status")
199
+ ended = str(r.get("ended_at") or "")
200
+ if st in ("done", "failed", "cancelled") and ended.startswith(today):
201
+ if st == "done":
202
+ today_done += 1
203
+ elif st == "failed":
204
+ today_failed += 1
205
+ if len(recent) < _RECENT_LIMIT and st in ("done", "failed", "cancelled") and ended:
206
+ recent.append({
207
+ "run_id": r.get("id"), "task_id": r.get("task_id"),
208
+ "title": r.get("title") or "", "status": st,
209
+ "ended_at": ended, "duration_s": _duration_s(r),
210
+ "error": (r.get("error") or "")[:80],
211
+ })
212
+
213
+ trend, models, usage_today = [], [], {"tokens": 0, "calls": 0, "cost_usd": 0.0}
214
+ try:
215
+ s = usage.summary(days=7)
216
+ trend = [{"day": d.get("day"), "tokens": d.get("tokens") or 0}
217
+ for d in (s.get("by_day") or [])]
218
+ for row in (s.get("by_model") or [])[:5]:
219
+ if not row.get("tokens"):
220
+ continue
221
+ models.append({"model": row.get("key") or "?",
222
+ "tokens": row.get("tokens") or 0,
223
+ "calls": row.get("calls") or 0})
224
+ for d in s.get("by_day") or []:
225
+ if str(d.get("day")) == today:
226
+ usage_today = {"tokens": d.get("tokens") or 0,
227
+ "calls": d.get("calls") or 0,
228
+ "cost_usd": round(float(d.get("cost_usd") or 0.0), 3)}
229
+ break
230
+ except Exception:
231
+ pass
232
+
233
+ return {
234
+ "now": time.strftime("%Y-%m-%d %H:%M:%S"),
235
+ "today": today,
236
+ "running": running,
237
+ "queued": queued,
238
+ "today_done": today_done,
239
+ "today_failed": today_failed,
240
+ "recent": recent,
241
+ "trend": trend,
242
+ "models": models,
243
+ "usage_today": usage_today,
244
+ "pool": jobs.workers_info(),
245
+ "health": health.snapshot().get("providers") or [],
246
+ }