c0de-agent 1.11.0 → 1.13.0

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 (177) hide show
  1. package/README.md +63 -16
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +151 -1
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +63 -3
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +109 -3
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +110 -11
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +78 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/compact.d.ts +2 -0
  24. package/dist/core/compact.js +1 -0
  25. package/dist/core/config.d.ts +53 -2
  26. package/dist/core/config.js +235 -19
  27. package/dist/core/loop/compaction.d.ts +2 -1
  28. package/dist/core/loop/compaction.js +19 -6
  29. package/dist/core/loop/segment.d.ts +3 -1
  30. package/dist/core/loop/segment.js +24 -1
  31. package/dist/core/loop/stream-collect.js +9 -1
  32. package/dist/core/loop/subagent.js +88 -37
  33. package/dist/core/loop.d.ts +7 -0
  34. package/dist/core/loop.js +90 -6
  35. package/dist/core/redact.d.ts +11 -0
  36. package/dist/core/redact.js +49 -0
  37. package/dist/core/slash.d.ts +10 -0
  38. package/dist/core/slash.js +237 -65
  39. package/dist/core/title.js +7 -1
  40. package/dist/core/types.d.ts +22 -1
  41. package/dist/core/workflows/context.js +44 -4
  42. package/dist/core/workflows/discovery.d.ts +2 -2
  43. package/dist/core/workflows/discovery.js +5 -7
  44. package/dist/core/workflows/index.d.ts +2 -1
  45. package/dist/core/workflows/index.js +2 -1
  46. package/dist/core/workflows/registry.d.ts +13 -3
  47. package/dist/core/workflows/registry.js +14 -9
  48. package/dist/core/workflows/resolve.d.ts +22 -0
  49. package/dist/core/workflows/resolve.js +23 -0
  50. package/dist/core/workflows/runtime.d.ts +6 -1
  51. package/dist/core/workflows/runtime.js +19 -2
  52. package/dist/db/schema.d.ts +373 -1
  53. package/dist/db/schema.js +60 -4
  54. package/dist/kanban/index.d.ts +2 -1
  55. package/dist/kanban/index.js +1 -1
  56. package/dist/kanban/store.d.ts +87 -1
  57. package/dist/kanban/store.js +429 -22
  58. package/dist/llm/index.d.ts +1 -1
  59. package/dist/llm/index.js +1 -1
  60. package/dist/llm/provider.js +1 -1
  61. package/dist/llm/registry.d.ts +7 -1
  62. package/dist/llm/registry.js +7 -1
  63. package/dist/llm/retry.d.ts +5 -2
  64. package/dist/llm/retry.js +6 -5
  65. package/dist/llm/routing.d.ts +24 -1
  66. package/dist/llm/routing.js +29 -1
  67. package/dist/plugins/init.d.ts +7 -0
  68. package/dist/plugins/init.js +22 -1
  69. package/dist/plugins/loader.d.ts +7 -1
  70. package/dist/plugins/loader.js +12 -4
  71. package/dist/project/index.d.ts +1 -1
  72. package/dist/project/index.js +1 -1
  73. package/dist/project/project.d.ts +37 -0
  74. package/dist/project/project.js +142 -1
  75. package/dist/project/trust.d.ts +65 -0
  76. package/dist/project/trust.js +328 -0
  77. package/dist/server/agent-manager.d.ts +15 -2
  78. package/dist/server/agent-manager.js +18 -3
  79. package/dist/server/app.js +45 -30
  80. package/dist/server/auth-manager.d.ts +26 -9
  81. package/dist/server/auth-manager.js +78 -42
  82. package/dist/server/context.js +14 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/index.js +1 -1
  85. package/dist/server/middleware/auth.d.ts +11 -1
  86. package/dist/server/middleware/auth.js +25 -3
  87. package/dist/server/permission/interactive.d.ts +11 -3
  88. package/dist/server/permission/interactive.js +23 -4
  89. package/dist/server/permission/store.d.ts +14 -2
  90. package/dist/server/permission/store.js +39 -3
  91. package/dist/server/registry-config.d.ts +14 -0
  92. package/dist/server/registry-config.js +44 -0
  93. package/dist/server/routes/auth.js +66 -10
  94. package/dist/server/routes/chat.d.ts +21 -0
  95. package/dist/server/routes/chat.js +685 -52
  96. package/dist/server/routes/commands.d.ts +4 -2
  97. package/dist/server/routes/commands.js +9 -4
  98. package/dist/server/routes/config.js +180 -23
  99. package/dist/server/routes/files.js +142 -28
  100. package/dist/server/routes/kanban.js +218 -26
  101. package/dist/server/routes/permissions.d.ts +0 -4
  102. package/dist/server/routes/permissions.js +140 -7
  103. package/dist/server/routes/project.js +132 -29
  104. package/dist/server/routes/provider.js +54 -3
  105. package/dist/server/routes/session.js +463 -23
  106. package/dist/server/routes/tool.js +24 -5
  107. package/dist/server/routes/update.d.ts +11 -5
  108. package/dist/server/routes/update.js +145 -29
  109. package/dist/server/routes/usage.d.ts +4 -0
  110. package/dist/server/routes/usage.js +133 -0
  111. package/dist/server/routes/workflows.js +476 -87
  112. package/dist/server/server.d.ts +16 -13
  113. package/dist/server/server.js +364 -107
  114. package/dist/server/terminal/pty-manager.d.ts +12 -0
  115. package/dist/server/terminal/pty-manager.js +63 -7
  116. package/dist/server/types.d.ts +16 -0
  117. package/dist/session/archive.d.ts +3 -1
  118. package/dist/session/archive.js +13 -1
  119. package/dist/session/branch.d.ts +2 -1
  120. package/dist/session/branch.js +92 -9
  121. package/dist/session/context.js +28 -2
  122. package/dist/session/import.d.ts +44 -0
  123. package/dist/session/import.js +83 -0
  124. package/dist/session/index.d.ts +1 -1
  125. package/dist/session/index.js +1 -1
  126. package/dist/session/jobs.d.ts +12 -0
  127. package/dist/session/jobs.js +53 -0
  128. package/dist/session/message.d.ts +7 -1
  129. package/dist/session/message.js +21 -1
  130. package/dist/session/session.d.ts +186 -12
  131. package/dist/session/session.js +577 -33
  132. package/dist/session/snapshot.d.ts +5 -2
  133. package/dist/session/snapshot.js +10 -2
  134. package/dist/session/types.d.ts +13 -1
  135. package/dist/session/usage.d.ts +62 -0
  136. package/dist/session/usage.js +194 -0
  137. package/dist/shared/types/agent.d.ts +29 -2
  138. package/dist/shared/types/config.d.ts +64 -5
  139. package/dist/shared/types/kanban.d.ts +17 -2
  140. package/dist/shared/types/message.d.ts +23 -0
  141. package/dist/tools/builtin/kanban.js +13 -2
  142. package/dist/tools/index.d.ts +14 -3
  143. package/dist/tools/index.js +23 -5
  144. package/dist/tools/websearch/index.d.ts +5 -1
  145. package/dist/tools/websearch/index.js +46 -7
  146. package/dist/tools/websearch/providers/brave.js +2 -2
  147. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  148. package/dist/tools/websearch/providers/tavily.js +2 -2
  149. package/dist/tools/websearch/types.d.ts +5 -1
  150. package/dist/tools/websearch/types.js +7 -1
  151. package/dist/update/index.d.ts +3 -3
  152. package/dist/update/index.js +2 -2
  153. package/dist/update/ipc.d.ts +13 -2
  154. package/dist/update/ipc.js +36 -2
  155. package/dist/update/scheduler.js +5 -2
  156. package/dist/update/snapshot.d.ts +18 -3
  157. package/dist/update/snapshot.js +5 -2
  158. package/dist/update/updater.d.ts +17 -1
  159. package/dist/update/updater.js +47 -18
  160. package/dist/update/version.d.ts +3 -0
  161. package/dist/update/version.js +9 -3
  162. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  163. package/drizzle/0010_usage_events_ledger.sql +17 -0
  164. package/drizzle/0011_slow_slapstick.sql +12 -0
  165. package/drizzle/0012_chief_timeslip.sql +1 -0
  166. package/drizzle/0013_swift_zeigeist.sql +1 -0
  167. package/drizzle/0014_flimsy_legion.sql +1 -0
  168. package/drizzle/meta/0008_snapshot.json +875 -0
  169. package/drizzle/meta/0010_snapshot.json +1010 -0
  170. package/drizzle/meta/0011_snapshot.json +1055 -0
  171. package/drizzle/meta/0012_snapshot.json +1062 -0
  172. package/drizzle/meta/0013_snapshot.json +1068 -0
  173. package/drizzle/meta/0014_snapshot.json +1075 -0
  174. package/drizzle/meta/_journal.json +42 -0
  175. package/package.json +42 -42
  176. package/dist/core/agents/discovery.d.ts +0 -6
  177. package/dist/core/agents/discovery.js +0 -96
@@ -1,29 +1,96 @@
1
1
  import { Hono } from 'hono';
2
- import { getCurrentVersion, performHotUpdate, serializeSessions } from '../../update/index.js';
2
+ import { getSession } from '../../session/session.js';
3
+ import { getCurrentVersion, manualInstallCommand, performHandoff, performInstall, serializeSessions, } from '../../update/index.js';
3
4
  import { apiError } from '../middleware/error.js';
5
+ import { captureForegroundCommand } from '../terminal/pty-manager.js';
4
6
  /**
5
7
  * GET /api/update — 返回后台调度器缓存的版本检查结果(spec §18.1)。
6
8
  * 路由本身不触发 npm registry 请求;调度器周期性 checkNow 写入缓存,
7
9
  * 路由读缓存(避免每个前端轮询都打外网)。
8
10
  *
9
- * POST /api/update/apply — 手动触发热更新(spec §18.2):
10
- * 序列化当前会话 → performHotUpdate(npm install -g + spawn 新实例 + handoffPort)。
11
- * 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
12
- * 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
13
- * dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
11
+ * POST /api/update/apply — 手动触发热更新(spec §18.2),P0 修复后顺序:
12
+ * 1. checkNow 确认有更新;
13
+ * 2. performInstall(仅安装新版本)——失败时**尚未暂停任何会话**,
14
+ * 直接返回可操作错误(409 + 手动更新命令),用户工作零损失;
15
+ * 3. 安装成功 → pauseAll 暂停活跃 run 至安全点(原子操作完成);
16
+ * 4. serializeSessions → performHandoff(写快照 + spawn 新实例);
17
+ * 5. spawn 失败 → resumeAll 回滚暂停的会话,返回明确错误。
18
+ *
19
+ * 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
20
+ * 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
21
+ * dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
14
22
  */
15
23
  function createUpdateRoute(ctx) {
16
24
  const app = new Hono();
17
- app.get('/', (c) => {
25
+ app.get('/', async (c) => {
26
+ // 热更新影响面(P2-11):apply 前由前端确认框逐项展示受影响对象。
27
+ // 顶层 run(主 agent)+ 终端数;子 agent 归父会话,不重复列出。
28
+ const active = ctx.agentManager.listActive();
29
+ const topRuns = active.filter((r) => !r.parentSessionId);
30
+ // P3:受影响会话集合——全部活跃 run 会话 + workflow busy 的发起/工作流会话
31
+ //(工作流运行期发起会话无主 run,但其挂起权限同样会被更新中断)。
32
+ const affectedSessionIds = new Set(active.map((r) => r.sessionId));
33
+ for (const [initiator, wf] of ctx.workflowBusyBySession) {
34
+ affectedSessionIds.add(initiator);
35
+ affectedSessionIds.add(wf);
36
+ }
37
+ const runs = [];
38
+ for (const r of topRuns) {
39
+ let title = r.sessionId.slice(0, 8);
40
+ try {
41
+ const session = await getSession(ctx.db, r.sessionId);
42
+ if (session)
43
+ title = session.title;
44
+ }
45
+ catch {
46
+ // 会话查询失败:回退 id 前缀展示
47
+ }
48
+ runs.push({
49
+ sessionId: r.sessionId,
50
+ title,
51
+ ...(r.agentType ? { agentType: r.agentType } : {}),
52
+ ...(r.status ? { status: r.status } : {}),
53
+ ...(r.currentTool ? { currentTool: r.currentTool } : {}),
54
+ });
55
+ }
56
+ const impact = {
57
+ runs,
58
+ terminalCount: ctx.ptyManager.list().length,
59
+ // P1:逐项列出终端(标题 + shell + 目录),确认弹窗据此展示——
60
+ // 用户可判断被关闭的终端是否正在跑重要进程。
61
+ terminals: ctx.ptyManager.list().map((t) => {
62
+ const command = captureForegroundCommand(t.pid);
63
+ return {
64
+ id: t.id,
65
+ title: t.title,
66
+ shell: t.shell,
67
+ cwd: t.cwd,
68
+ // P3-7:检测到的前台命令(仅单一前台子进程时非空),供确认框勾选「自动重启」。
69
+ ...(command ? { command } : {}),
70
+ };
71
+ }),
72
+ // P3-9:正在等待确认的权限请求——更新会中断其所属 run,弹窗静默失效
73
+ // (隐含按拒绝处理),确认层明示数量让用户知情。
74
+ // P3 修复:仅统计受影响会话(活跃 run 会话 + workflow busy 发起会话)的挂起
75
+ // 请求——此前用全局 store.size(),其他无关会话/已结束 run 的遗留 pending
76
+ // 也计入,数量虚高。
77
+ pendingPermissionCount: ctx.permissionStore.countForSessions(affectedSessionIds),
78
+ };
18
79
  // P2-8:update.enabled=false 时无 handoff server,apply 必然 409。
19
80
  // 不再触发 checkNow,也不返回 hasUpdate,避免横幅出现一个点了必失败的应用按钮。
20
81
  if (ctx.config.update.enabled === false) {
21
82
  const v = getCurrentVersion();
22
- return c.json({ hasUpdate: false, disabled: true, currentVersion: v, latestVersion: v });
83
+ return c.json({
84
+ hasUpdate: false,
85
+ disabled: true,
86
+ currentVersion: v,
87
+ latestVersion: v,
88
+ impact,
89
+ });
23
90
  }
24
91
  const cached = ctx.updateScheduler.getLastResult();
25
92
  if (cached)
26
- return c.json(cached);
93
+ return c.json({ ...cached, impact });
27
94
  // 无缓存(首次启动延迟未到):同步触发一次,避免前端首屏空。
28
95
  // 不 await——保持 GET 语义非阻塞;前端下次轮询拿到结果。
29
96
  void ctx.updateScheduler.checkNow();
@@ -32,46 +99,95 @@ function createUpdateRoute(ctx) {
32
99
  hasUpdate: false,
33
100
  currentVersion: v,
34
101
  latestVersion: v,
102
+ impact,
35
103
  });
36
104
  });
37
105
  app.post('/apply', async (c) => {
38
106
  if (!ctx.handoff) {
39
107
  return apiError(c, 409, 'HOT_UPDATE_UNAVAILABLE', '热更新仅在独立 serve 进程可用(dev 模式请手动更新)');
40
108
  }
109
+ // P3-7:用户勾选「更新后自动重启」的终端 id;命中者在快照中携带前台命令。
110
+ const body = (await c.req.json().catch(() => ({})));
111
+ const rerunIds = new Set(Array.isArray(body.rerunTerminalIds)
112
+ ? body.rerunTerminalIds.filter((x) => typeof x === 'string')
113
+ : []);
41
114
  const result = await ctx.updateScheduler.checkNow();
42
115
  if (!result.hasUpdate) {
116
+ // P3-8:检查失败与「已是最新」分开——网络/registry 异常给可操作的
117
+ // 重试指引,不再误导为「无需更新」。
118
+ if (result.checkError) {
119
+ return apiError(c, 502, 'UPDATE_CHECK_FAILED', '无法检查最新版本(registry 连接失败),请检查网络后重试');
120
+ }
43
121
  return apiError(c, 409, 'NO_UPDATE', '已是最新版本,无需热更新');
44
122
  }
45
- // P1-8:热更新前先暂停所有活跃 run,等其到达安全暂停点(原子操作完成)。
46
- // 暂停状态已持久化(lastRun.status='paused'),新实例接管后可恢复继续执行。
123
+ // P0 修复:install 先行。失败(网络/权限/unknown 安装方式)时未触碰任何
124
+ // 会话状态,直接返回可操作错误——旧流程先 pause 后 install,失败时
125
+ // 已暂停/强杀的会话无回滚路径。
126
+ const install = await performInstall({});
127
+ if (install._tag === 'install_failed') {
128
+ return apiError(c, 409, 'INSTALL_FAILED', install.error, {
129
+ command: manualInstallCommand('c0de-agent'),
130
+ });
131
+ }
132
+ if (install._tag === 'manual_install_required') {
133
+ return apiError(c, 409, 'MANUAL_UPDATE_REQUIRED', install.error, {
134
+ command: install.command,
135
+ });
136
+ }
137
+ const method = { kind: install.installMethod };
138
+ // 安装已成功 → 暂停所有活跃 run,等其到达安全暂停点(原子操作完成)。
139
+ // 注意:暂停不是「可恢复快照」——新实例只有 DB 状态、无内存 agent run,
140
+ // 进行中的对话会以 interrupted 呈现,用户重发上一条消息即可继续(幂等
141
+ // 检查跳过重复 append)。工具副作用不回滚。
47
142
  const pauseTimeoutMs = ctx.config.update.pauseTimeoutMs ?? 30_000;
48
143
  const pauseResult = await ctx.agentManager.pauseAll(pauseTimeoutMs);
49
- const snapshot = await serializeSessions(ctx.db, ctx.config);
50
- const r = await performHotUpdate(snapshot, {
144
+ const snapshot = await serializeSessions(ctx.db, ctx.config,
145
+ // P1:终端元信息随快照迁移——新实例按原 id 原位重建 shell,
146
+ // 前端持久化布局重连无感(进程内状态无法续命,这是最优恢复)。
147
+ ctx.ptyManager.list().map((t) => {
148
+ const command = rerunIds.has(t.id) ? captureForegroundCommand(t.pid) : null;
149
+ return {
150
+ id: t.id,
151
+ shell: t.shell,
152
+ cwd: t.cwd,
153
+ title: t.title,
154
+ ...(t.projectId ? { projectId: t.projectId } : {}),
155
+ ...(command ? { command } : {}),
156
+ };
157
+ }));
158
+ const r = await performHandoff(snapshot, method, {
51
159
  handoffPort: ctx.handoff.port,
52
160
  port: ctx.port,
161
+ ...(ctx.host ? { host: ctx.host } : {}),
53
162
  // P2-16:传当前 bootstrap(轮换后 ctx.authToken 可能已过期);
54
163
  // 旧实例 handoff 端点经 verifyHandoff 接受当前/历史 bootstrap 与设备 token。
55
164
  authToken: ctx.authManager?.bootstrap ?? ctx.authToken,
56
165
  });
57
- if (r._tag === 'success') {
58
- return c.json({
59
- ok: true,
60
- snapshotPath: r.snapshotPath,
61
- latestVersion: result.latestVersion,
62
- installMethod: r.installMethod,
63
- pausedSessions: pauseResult.paused,
64
- forcedAbort: pauseResult.forcedAbort,
166
+ if (r._tag !== 'success') {
167
+ // P0 回滚:spawn 失败(含程序文件未就绪等)→ 恢复被暂停的会话。
168
+ // forcedAbort 的无法恢复(已中止),但这是超时兜底,属可接受损失。
169
+ for (const id of pauseResult.pausedIds) {
170
+ ctx.agentManager.resume(id);
171
+ }
172
+ if (r._tag === 'manual_install_required') {
173
+ return apiError(c, 409, 'MANUAL_UPDATE_REQUIRED', r.error, {
174
+ command: r.command,
175
+ snapshotPath: r.snapshotPath,
176
+ resumedSessions: pauseResult.pausedIds.length,
177
+ });
178
+ }
179
+ return apiError(c, 500, 'HOT_UPDATE_FAILED', `${r._tag}: ${r.error}`, {
180
+ resumedSessions: pauseResult.pausedIds.length,
65
181
  });
66
182
  }
67
- if (r._tag === 'manual_install_required') {
68
- // P0-2:无法自动安装(未知安装方式/自动安装失败/等待超时)→ 引导手动更新
69
- return apiError(c, 409, 'MANUAL_UPDATE_REQUIRED', r.error, {
70
- command: r.command,
71
- snapshotPath: r.snapshotPath,
72
- });
73
- }
74
- return apiError(c, 500, 'HOT_UPDATE_FAILED', `${r._tag}: ${'error' in r ? r.error : 'unknown'}`);
183
+ return c.json({
184
+ ok: true,
185
+ snapshotPath: r.snapshotPath,
186
+ latestVersion: result.latestVersion,
187
+ installMethod: r.installMethod,
188
+ pausedSessions: pauseResult.paused,
189
+ forcedAbort: pauseResult.forcedAbort,
190
+ });
75
191
  });
76
192
  return app;
77
193
  }
@@ -0,0 +1,4 @@
1
+ import { Hono } from 'hono';
2
+ import type { ServerContext } from '../types.js';
3
+ declare function createUsageRoute(ctx: ServerContext): Hono;
4
+ export { createUsageRoute };
@@ -0,0 +1,133 @@
1
+ // src/server/routes/usage.ts
2
+ // 用量/成本聚合端点(P2 成本聚合视图)。数据源:usage_events 账本表——
3
+ // 与 session 生命周期解耦:会话物理清除(回收站到期宽限期满/彻底删除/临时会话
4
+ // 清理)不抹去已发生花费。cost=null 的调用(价格未知)按 $0 计入并单独计数
5
+ // (unknownCostCalls),前端据此提示预算可能低估。
6
+ // ?projectId= 过滤为「该项目口径」聚合:项目设置页的月度预算按本项目成本对比,
7
+ // 不再被其他项目的花费触发告警。月份键为本地时区 YYYY-MM(与服务端同机)。
8
+ import { eq, sql } from 'drizzle-orm';
9
+ import { Hono } from 'hono';
10
+ import { usageEvents } from '../../db/schema.js';
11
+ import { PRICE_CATALOG_VERSION } from '../../llm/registry.js';
12
+ import { localMonthKey } from '../../session/usage.js';
13
+ const emptyTotals = () => ({
14
+ inputTokens: 0,
15
+ outputTokens: 0,
16
+ cacheRead: 0,
17
+ cost: 0,
18
+ unknownCostCalls: 0,
19
+ calls: 0,
20
+ });
21
+ const add = (t, input, output, cacheRead, cost, unknownCostCalls, calls) => {
22
+ t.inputTokens += input;
23
+ t.outputTokens += output;
24
+ t.cacheRead += cacheRead;
25
+ t.cost += cost;
26
+ t.unknownCostCalls += unknownCostCalls;
27
+ t.calls += calls;
28
+ };
29
+ /** 服务端本地时区名(SQL 月份分组用;与 localMonthKey 的本地口径一致)。
30
+ * IANA 名在 PGLite 的 tzdata 中可用(已实证 Asia/Shanghai 等常见时区);
31
+ * 极端不可用时回退 UTC——分组可能跨月边界漂移,但聚合不再 O(全表)。 */
32
+ function serverTimeZone() {
33
+ try {
34
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
35
+ }
36
+ catch {
37
+ return 'UTC';
38
+ }
39
+ }
40
+ function createUsageRoute(ctx) {
41
+ const app = new Hono();
42
+ // GET /api/usage/summary?projectId= — 总量 + 按月 + 按模型。
43
+ // projectId 提供时仅聚合该项目的调用(项目设置页预算口径);
44
+ // 省略时全库聚合(含已删除项目产生的无归属调用)。
45
+ // P2 修复:此前全表 select 进内存手工聚合,usage_events 无界增长后每次
46
+ // 打开用量面板 O(全部行) 内存与耗时。现在按(本地月份,provider, model,
47
+ // projectId)SQL GROUP BY 聚合,结果行数 O(月份数 × 模型数)。
48
+ app.get('/summary', async (c) => {
49
+ const projectId = c.req.query('projectId');
50
+ const where = projectId ? eq(usageEvents.projectId, projectId) : undefined;
51
+ // SQL 注入面:tz 名来自本机运行时(Intl),经字符白名单校验后以内联字面量
52
+ // 拼接(白名单已排除单引号,无注入面)。必须内联而非参数占位——SELECT 与
53
+ // GROUP BY 中各出现一次的参数化表达式被 PG 判定为不同表达式而拒绝 group by。
54
+ const tz = serverTimeZone();
55
+ if (!/^[A-Za-z0-9_+\-/]+$/.test(tz)) {
56
+ const monthKey = localMonthKey(Date.now());
57
+ return c.json({
58
+ totals: emptyTotals(),
59
+ byMonth: [],
60
+ byModel: [],
61
+ currentMonth: { key: monthKey, ...emptyTotals() },
62
+ });
63
+ }
64
+ const monthExpr = sql `to_char(to_timestamp(${usageEvents.timestamp} / 1000.0) AT TIME ZONE '${sql.raw(tz)}', 'YYYY-MM')`;
65
+ const rows = await ctx.db.db
66
+ .select({
67
+ month: monthExpr,
68
+ provider: usageEvents.provider,
69
+ model: usageEvents.model,
70
+ projectId: usageEvents.projectId,
71
+ input: sql `coalesce(sum(${usageEvents.inputTokens}), 0)::float8`,
72
+ output: sql `coalesce(sum(${usageEvents.outputTokens}), 0)::float8`,
73
+ cacheRead: sql `coalesce(sum(${usageEvents.cacheRead}), 0)::float8`,
74
+ cost: sql `coalesce(sum(${usageEvents.cost}), 0)::float8`,
75
+ calls: sql `count(*)::int`,
76
+ unknownCalls: sql `count(*) filter (where ${usageEvents.cost} is null)::int`,
77
+ })
78
+ .from(usageEvents)
79
+ .where(where ?? sql `true`)
80
+ .groupBy(({ month, provider, model, projectId }) => [month, provider, model, projectId]);
81
+ const totals = emptyTotals();
82
+ const byMonth = new Map();
83
+ const byModel = new Map();
84
+ // P1-3:未归属任何项目的调用(CLI 未绑定会话、孤儿会话)单独聚合——
85
+ // 它们不计入任何项目预算,全局视图需明示这一桶。
86
+ const unassigned = emptyTotals();
87
+ for (const row of rows) {
88
+ const modelLabel = `${row.provider ?? '未知'}/${row.model ?? '未知'}`;
89
+ // real(float4)列 SQL 求和引入浮点噪声(0.1 → 0.10000000149011612),
90
+ // 金额口径按 6 位小数规整(USD 分以下精度足够,且与旧 JS 侧求和结果一致)。
91
+ const cost = Math.round((row.cost ?? 0) * 1e6) / 1e6;
92
+ const input = row.input;
93
+ const output = row.output;
94
+ const cacheRead = row.cacheRead;
95
+ const unknownCalls = row.unknownCalls;
96
+ const calls = row.calls;
97
+ add(totals, input, output, cacheRead, cost, unknownCalls, calls);
98
+ const m = byMonth.get(row.month) ?? emptyTotals();
99
+ add(m, input, output, cacheRead, cost, unknownCalls, calls);
100
+ byMonth.set(row.month, m);
101
+ const mdl = byModel.get(modelLabel) ?? emptyTotals();
102
+ add(mdl, input, output, cacheRead, cost, unknownCalls, calls);
103
+ byModel.set(modelLabel, mdl);
104
+ if (row.projectId === null) {
105
+ add(unassigned, input, output, cacheRead, cost, unknownCalls, calls);
106
+ }
107
+ }
108
+ // P1-4:本月口径由服务端本地时区计算并下发——此前前端用自己的时区
109
+ // 过滤 byMonth 找「本月」,远程访问/容器时区不同时与预算暂停判定
110
+ // (服务端 budgetOverageParts 同用本地时区口径)不一致,徽标显示未超支却被打断。
111
+ const monthKey = localMonthKey(Date.now());
112
+ const current = byMonth.get(monthKey) ?? emptyTotals();
113
+ return c.json({
114
+ totals,
115
+ // H3:价目版本随响应下发,前端标注估算可能过期。
116
+ priceCatalogVersion: PRICE_CATALOG_VERSION,
117
+ // P1-4:服务端权威本月(key + 聚合),前端徽标/面板不再自行计算。
118
+ currentMonth: { key: monthKey, ...current },
119
+ // P1-3:仅全局视图下发(projectId 过滤时未归属桶与该项目无关)。
120
+ ...(projectId ? {} : { unassigned }),
121
+ byMonth: [...byMonth.entries()]
122
+ .sort((a, b) => (a[0] < b[0] ? 1 : -1))
123
+ .slice(0, 12)
124
+ .map(([month, t]) => ({ month, ...t })),
125
+ byModel: [...byModel.entries()]
126
+ .sort((a, b) => b[1].cost - a[1].cost)
127
+ .slice(0, 10)
128
+ .map(([model, t]) => ({ model, ...t })),
129
+ });
130
+ });
131
+ return app;
132
+ }
133
+ export { createUsageRoute };