diff --git a/docs/chorus-observability-design.md b/docs/chorus-observability-design.md new file mode 100644 index 00000000..ba41abaa --- /dev/null +++ b/docs/chorus-observability-design.md @@ -0,0 +1,662 @@ +# Chorus 可观测性技术设计文档 + +## 1. 概述 + +Chorus 的可观测性系统采用三层采集架构,在不引入任何额外 Daemon 进程的前提下,自动追踪 Agent 的工具调用和 Token 消耗。Agent 完全无感知——所有采集通过已有的 MCP 拦截器和 Claude Code 插件 Hook 机制自动完成。 + +**核心设计原则:** +- 零 Daemon:不需要额外常驻进程,利用 CC 内置 Hook 机制 +- Agent 无感:采集逻辑在框架层自动执行,不修改任何 Agent Prompt 或行为 +- 精确归因:每次工具调用和 Token 消耗都能关联到具体的 Task / Proposal / Idea +- 异步不阻塞:所有采集操作异步执行,不增加工具调用延迟 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Chorus 可观测性三层架构 │ +├─────────────┬─────────────────────┬─────────────────────────┤ +│ Layer 1 │ Layer 2 │ Layer 3 │ +│ 服务端自动 │ 客户端本地聚合 │ 转录文件解析 │ +│ (零改动) │ (Hook 改动) │ (SubagentStop) │ +├─────────────┼─────────────────────┼─────────────────────────┤ +│ Chorus MCP │ Bash/Read/Write 等 │ Token 精确用量 │ +│ 工具调用 │ CC 内置工具调用 │ (input/output/cache) │ +│ 执行时长/错误 │ 输入/输出规模 │ 含 thinking/reasoning │ +│ 实体关联 │ Active Context 关联 │ 非工具调用的消耗 │ +└─────────────┴─────────────────────┴─────────────────────────┘ +``` + +**关键区分:** Layer 1 + 2 采集的是**工具级别的调用明细**(哪个工具被调用了、多少次、耗时多少)。Layer 3 采集的是**总 Token 消耗**,包括 thinking、reasoning、正常文本输出等不产生工具调用的 Token 消耗。两者互补——Layer 3 是消耗总量的 source of truth,Layer 1 + 2 提供调用粒度的明细。 + +--- + +## 2. 数据模型 + +### 2.1 ToolUsageEvent(工具调用事件) + +每次工具调用产生一条记录,无论来源是服务端 MCP 还是客户端 Hook 上报。 + +```prisma +model ToolUsageEvent { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + companyUuid String + agentUuid String + sessionUuid String? + toolName String // e.g. "chorus_claim_task", "Bash", "Read" + source String @default("mcp") // "mcp" (Layer 1) | "client" (Layer 2) + durationMs Int // 执行耗时(Layer 1 精确;Layer 2 默认 0) + inputSize Int // JSON.stringify(params).length + outputSize Int // JSON.stringify(result).length + isError Boolean @default(false) + errorText String? + entityType String? // "task" | "idea" | "proposal" | "document" + entityUuid String? + projectUuid String? + createdAt DateTime @default(now()) + + @@index([companyUuid, createdAt]) + @@index([agentUuid, createdAt]) + @@index([sessionUuid]) + @@index([entityType, entityUuid]) + @@index([projectUuid, createdAt]) +} +``` + +### 2.2 AgentSession.tokenUsage(Session Token 用量) + +在已有的 `AgentSession` 模型上新增 JSON 字段,存储从转录文件解析出的精确 Token 用量。 + +```prisma +model AgentSession { + // ... 已有字段 ... + tokenUsage Json? // { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } +} +``` + +Token 用量是**累加式**更新——同一个 Session 可能收到多次上报(主 Agent + 多个 Sub-agent),服务端自动合并。 + +--- + +## 3. Layer 1:服务端 MCP Tool Logger 持久化 + +### 3.1 工作原理 + +`src/mcp/tools/tool-logger.ts` 已经通过猴子补丁 `server.registerTool` 拦截了所有 MCP 工具调用。在此基础上增加异步数据库写入。 + +### 3.2 数据流 + +``` +Agent 调用 MCP 工具 + → tool-logger wrappedHandler 拦截 + → 执行原始 handler,计时 + → 构建 ToolUsageEvent 数据 + → detectResource() 提取实体关联(复用 presence.ts) + → resolveProjectUuid() 从 DB 补全 projectUuid + → fire-and-forget persistToolUsage() → Prisma 写入 + → 返回原始结果(不阻塞) +``` + +### 3.3 实体关联 + +复用 `src/mcp/tools/presence.ts` 的 `detectResource()` 函数,按优先级从参数提取实体 UUID: + +1. `taskUuid` → entityType = "task" +2. `ideaUuid` → entityType = "idea" +3. `proposalUuid` → entityType = "proposal" +4. `documentUuid` → entityType = "document" +5. `targetUuid` + `targetType`(多态模式) + +当参数中不包含 `projectUuid` 时,通过 `resolveProjectUuid()` 反查数据库(结果缓存在 Session 级 Map 中,避免重复查询)。 + +### 3.4 关键实现 + +```typescript +// src/mcp/tools/tool-logger.ts (简化) +async function persistToolUsage(p: PersistParams): Promise { + const resource = detectResource(p.params, p.toolName); + let entityType = resource?.entityType ?? null; + let entityUuid = resource?.entityUuid ?? null; + let projectUuid = resource?.projectUuid ?? null; + + if (resource && !projectUuid) { + projectUuid = await resolveProjectUuid( + resource.entityType, resource.entityUuid, p.projectUuidCache + ); + } + + await prisma.toolUsageEvent.create({ + data: { + companyUuid: p.auth.companyUuid, + agentUuid: p.auth.actorUuid, + sessionUuid: extractSessionUuid(p.params), + toolName: p.toolName, + source: "mcp", + durationMs: p.durationMs, + inputSize: safeJsonSize(p.params), + outputSize: safeJsonSize(p.result), + isError: p.isError, + errorText: p.errorText, + entityType, entityUuid, projectUuid, + }, + }); +} +``` + +### 3.5 覆盖范围 + +- **覆盖:** 所有 60+ Chorus MCP 工具(chorus_claim_task, chorus_pm_create_proposal 等) +- **不覆盖:** CC 内置工具(Bash, Read, Write, Edit, Grep, Agent 等) + +--- + +## 4. Layer 2:CC 插件 Hook 本地聚合 + 批量上报 + +### 4.1 工作原理 + +通过 Claude Code 的 `PostToolUse` hook(`async: true`)捕获所有工具调用(包括 CC 内置工具),先写本地 JSONL 文件,在 TeammateIdle 和 SubagentStop 时批量上报。 + +### 4.2 数据流 + +``` +Claude Code 执行任意工具 + → PostToolUse hook 触发 on-post-tool-log.sh (async:true, 不阻塞) + → 解析 stdin 中的事件 JSON + → 如果是 Chorus MCP 工具,更新 Active Context (state.json) + → 构建紧凑 JSONL 行,追加到 .chorus/tool-log.jsonl + → Agent 继续工作(不受影响) + +TeammateIdle / SubagentStop 触发 + → flush-tool-log 命令 + → 原子移动 tool-log.jsonl → tool-log.jsonl.pending.$$ + → jq -cs 聚合为 JSON 数组 + → POST /api/agent-report/tool-usage (Bearer API Key 认证) + → 删除 pending 文件 +``` + +### 4.3 Hook 注册 + +`public/chorus-plugin/hooks/hooks.json` 中注册通配符 matcher: + +```json +{ + "type": "PostToolUse", + "matcher": ".*", + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/on-post-tool-log.sh", + "async": true + }] +} +``` + +### 4.4 on-post-tool-log.sh + +核心职责: +1. **解析事件**:从 stdin 读取 CC 提供的 JSON(tool_name, tool_input, tool_response, tool_use_id, agent_id) +2. **更新 Active Context**:对 `mcp__chorus__*` 工具,从 tool_input 提取实体 UUID,写入 `.chorus/state.json` +3. **写入 JSONL**:构建紧凑记录追加到 `.chorus/tool-log.jsonl` + +JSONL 行格式: +```json +{ + "ts": "2026-04-19T12:00:00Z", + "tool": "Bash", + "id": "tool_use_xxx", + "agent": "agent_id_or_null", + "input_len": 42, + "output_len": 1024, + "is_error": false, + "entity_type": "task", + "entity_uuid": "abc-123" +} +``` + +性能:纯本地文件追加,无网络请求,< 5ms。支持 flock 并发写入保护(macOS 无 flock 时降级为直接追加)。 + +### 4.5 Active Context 追踪 + +解决 CC 内置工具(Bash/Read/Write)不携带 Chorus 实体信息的问题。 + +**原理:** Agent 的工作是聚焦式的——围绕一个实体持续工作,然后切换到下一个。MCP 调用自然标记了当前焦点: + +``` +chorus_pm_create_proposal({proposalUuid: "abc"}) ← context = proposal abc +Bash("npm test") ← 继承 → proposal abc +Read("src/foo.ts") ← 继承 → proposal abc +chorus_update_task({taskUuid: "xyz"}) ← context 切换到 task xyz +Bash("git commit") ← 继承 → task xyz +``` + +`.chorus/state.json` 中维护的 Active Context 字段: +- `active_entity_type`:当前实体类型 +- `active_entity_uuid`:当前实体 UUID +- `active_task_uuid` / `active_proposal_uuid` 等:按类型的快捷引用 + +### 4.6 flush-tool-log + +`chorus-api.sh flush-tool-log [sessionUuid]` 命令: +1. 用 flock 获取锁,原子移动 JSONL 文件(避免上报期间新数据丢失) +2. 用 `jq -cs` 将 JSONL 聚合为 JSON 数组 +3. POST 到 `/api/agent-report/tool-usage` +4. 清理 pending 文件 + +触发时机: +- `on-teammate-idle.sh`:每次 TeammateIdle 事件(Agent 空闲时,通常每几十秒到几分钟) +- `on-subagent-stop.sh`:Sub-agent 退出前最后一次 flush + +--- + +## 5. Layer 3:SubagentStop 转录文件解析 + +### 5.1 工作原理 + +CC 的 `SubagentStop` hook 提供 `agent_transcript_path`——一个 JSONL 格式的完整对话转录文件。文件末尾的 `result` 类型消息包含精确的 Token 用量统计。 + +### 5.2 转录文件中的 Token 数据 + +CC 转录文件是 JSONL 格式,每行一条消息。assistant 类型的消息在 `.message.usage` 中携带该轮的 Token 用量: + +```json +{ + "type": "assistant", + "message": { + "role": "assistant", + "content": [...], + "usage": { + "input_tokens": 0, + "output_tokens": 598, + "cache_creation_input_tokens": 1223, + "cache_read_input_tokens": 101609 + } + } +} +``` + +**每个 assistant turn 有独立的 usage**,需要遍历所有 assistant 消息并累加。总和包含了整个 Session 的所有 Token 消耗——工具调用、thinking、reasoning、正常文本输出等。这是 Token 总量的唯一可靠来源。 + +### 5.3 实现 + +`on-subagent-stop.sh` 中的解析逻辑: + +```bash +TRANSCRIPT_PATH=$(echo "$EVENT" | jq -r '.agent_transcript_path // empty') + +if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then + # Sum usage across ALL assistant messages (each turn has its own usage) + USAGE_JSON=$(cat "$TRANSCRIPT_PATH" | jq -cs ' + [.[] | select(.type == "assistant") | .message.usage // empty] | + { + input_tokens: (map(.input_tokens // 0) | add // 0), + output_tokens: (map(.output_tokens // 0) | add // 0), + cache_creation_input_tokens: (map(.cache_creation_input_tokens // 0) | add // 0), + cache_read_input_tokens: (map(.cache_read_input_tokens // 0) | add // 0) + } + ') + + if [ -n "$USAGE_JSON" ]; then + curl -sS -X POST \ + -H "Authorization: Bearer ${CHORUS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "{\"sessionUuid\": \"$SESSION_UUID\", \"usage\": $USAGE_JSON}" \ + "${CHORUS_URL}/api/agent-report/token-usage" + fi +fi +``` + +### 5.4 Token 累加合并 + +服务端 `session.service.ts` 的 `updateTokenUsage()` 是累加式的——读取现有值,逐字段相加后写回。这保证了同一 Session 多次上报(多个 Sub-agent 共享 Session)的数据不丢失。 + +```typescript +const merged: TokenUsage = { + input_tokens: (existing.input_tokens ?? 0) + (usage.input_tokens ?? 0), + output_tokens: (existing.output_tokens ?? 0) + (usage.output_tokens ?? 0), + cache_creation_input_tokens: (existing.cache_creation_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0), + cache_read_input_tokens: (existing.cache_read_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0), +}; +``` + +--- + +## 6. API 设计 + +### 6.1 Agent 上报端点(Bearer API Key 认证,仅限 Agent 调用) + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/agent-report/tool-usage` | Layer 2 批量上报客户端工具调用 | +| POST | `/api/agent-report/token-usage` | Layer 3 上报 Session Token 用量 | + +**认证要求:** `auth.type === "agent"`。用户 Token 或 Super Admin 无法调用。 + +**POST /api/agent-report/tool-usage** + +```typescript +// 请求体 +{ + sessionUuid?: string, // 可选,关联到 AgentSession + events: Array<{ + tool: string, // 工具名称(必填) + id?: string, // tool_use_id + agent?: string, // sub-agent ID + ts?: string, // ISO 时间戳 + input_len?: number, + output_len?: number, + entity_type?: string, // Active Context 提供 + entity_uuid?: string, + project_uuid?: string, + is_error?: boolean, + error_text?: string, + }> +} +``` + +安全检查: +- sessionUuid 存在时,验证该 Session 属于当前 Agent(防止跨 Agent 写入) +- 单批次上限 500 条 + +**POST /api/agent-report/token-usage** + +```typescript +// 请求体 +{ + sessionUuid: string, // 必填 + usage: { + input_tokens?: number, + output_tokens?: number, + cache_creation_input_tokens?: number, + cache_read_input_tokens?: number, + } +} +``` + +同时接受 camelCase 和 snake_case 字段名(兼容不同来源)。 + +### 6.2 用户查询端点(用户 Session Cookie 认证) + +| 方法 | 路径 | 用途 | +|------|------|------| +| GET | `/api/projects/[uuid]/observability` | Agent 观测仪表盘(按项目聚合) | +| GET | `/api/projects/[uuid]/observability/entity` | 单实体 Token + 工具明细 | +| GET | `/api/projects/[uuid]/observability/idea/[ideaUuid]` | Idea 全生命周期 Token 追踪 | + +**GET /api/projects/[uuid]/observability?days=7|30|90** + +返回项目级 Agent 观测数据: +```typescript +{ + projectUuid: string, + dateRange: { days: number, from: string, to: string }, + agents: Array<{ + agentUuid: string, + agentName: string, + toolCallCount: number, + toolErrorCount: number, + totalInputSize: number, + totalOutputSize: number, + sessionTokens: TokenUsage, // 聚合该 Agent 所有 Session 的 Token + sessionCount: number, + dailySeries: Array<{ date: string, toolCallCount: number }>, + topTools: ToolBreakdownItem[], // 按调用次数排序的 Top 10 工具 + }> +} +``` + +**GET /api/projects/[uuid]/observability/entity?entityType=task&entityUuid=xxx** + +返回单实体的工具调用明细 + Session Token: +```typescript +{ + entityType: string, + entityUuid: string, + toolCallCount: number, + toolErrorCount: number, + toolBreakdown: ToolBreakdownItem[], // 按工具分组统计 + sessionTokens: TokenUsage, // 关联 Session 的 Token 总量 + sessionCount: number, + // 如果 entityType === "proposal",额外返回 drafting/review 拆分 + proposal?: { + drafting: { toolCallCount, sessionTokens, toolBreakdown }, + review: { toolCallCount, totalInputSize, totalOutputSize }, + } +} +``` + +**GET /api/projects/[uuid]/observability/idea/[ideaUuid]** + +返回 Idea 全生命周期(从 Elaboration 到 Verify)的 Token 追踪: +```typescript +{ + ideaUuid: string, + totals: { + toolCallCount: number, + sessionTokens: TokenUsage, // 所有阶段去重后的总 Token + }, + phases: Array<{ + phase: "elaboration" | "proposal" | "review" | "execution" | "verify", + toolCallCount: number, + toolErrorCount: number, + sessionTokens: TokenUsage, + toolBreakdown: ToolBreakdownItem[], + }>, + tasks: Array<{ + taskUuid: string, + title: string, + status: string, + toolCallCount: number, + sessionTokens: TokenUsage, + }>, +} +``` + +--- + +## 7. 实体关联策略 + +### 7.1 优先级链 + +``` +1. MCP 参数直接提取(Layer 1) + → detectResource() 从 taskUuid/proposalUuid/ideaUuid 等参数中读取 + → 准确率 100% + +2. Active Context 追踪(Layer 2) + → MCP 调用自动更新 state.json 中的 active_entity_type/uuid + → 非 MCP 工具调用继承最近的 Context + → 准确率 ~90%(聚焦工作时极高,快速切换时有偏差) + +3. Session Task Checkin(Layer 3 Token 归属) + → Sub-agent 的 Session 通过 SessionTaskCheckin 关联到 Task + → 准确率 ~100%(Sub-agent 通常只做一个 Task) + +4. 仅 Agent 级别 + → 以上都没有时,只记录 agentUuid + → 用于无法归因的调用(如纯浏览操作) +``` + +### 7.2 生命周期阶段分类 + +`observability.service.ts` 中的 `classifyPhase()` 将工具名映射到生命周期阶段: + +| 工具名模式 | 阶段 | +|------------|------| +| `*elaboration*` | elaboration | +| `chorus_admin_approve_proposal`, `*reject*`, `*close_proposal` | review | +| `chorus_admin_verify_task`, `*reopen_task`, `*submit_for_verify`, `*self_check` | verify | +| `*proposal*`, `*document_draft*`, `*task_draft*` | proposal | +| 无匹配 + entityType="idea" | elaboration(回退) | +| 无匹配 + entityType="proposal" | proposal(回退) | +| 无匹配 + entityType="task" | execution(回退) | + +--- + +## 8. 前端架构 + +### 8.1 页面结构 + +可观测性数据通过三种 UI 形态呈现,与 Chorus 现有页面结构无缝集成: + +| UI 位置 | 数据维度 | 接入方式 | +|---------|---------|---------| +| Idea Detail Panel → Tokens tab | Idea 全生命周期 Token | 新增 tab 到现有 TabId 联合类型 | +| Task Detail Panel → Tokens tab | 单 Task Token + 工具明细 | 新增 tab | +| Proposal Detail Page → sidebar Card | Proposal drafting/review Token | 新增 sidebar Card | +| `/projects/[uuid]/observability` | Agent 级仪表盘 | 新增独立页面 | + +### 8.2 Idea Tokens Tab + +集成到 `idea-detail-panel.tsx` 的 Tab 系统: + +```typescript +type TabId = "overview" | "elaboration" | "proposal" | "tasks" | "tokens" | "activity"; +``` + +`tokens` tab 始终可见(即使没有数据也显示空状态)。使用 `useIdeaLifecycleTokens` hook 获取数据,展示: + +- **汇总卡片**:Total Tokens (input+output+cache 总和),Cache Read,Tool Calls +- **生命周期拆分**:按 elaboration → proposal → review → execution → verify 分阶段展示 +- **Task 列表**:每个 Task 的 Token 消耗和调用次数,可点击展开 Task Detail + +### 8.3 Agent Observability 仪表盘 + +独立页面 `/projects/[uuid]/observability`,与 sidebar 导航中的 Activity 平级。 + +- **汇总卡片**:Total Tokens, Tool Calls, Cache Read, Error Rate +- **时间范围切换**:7d / 30d / 90d +- **Agent 列表**:左侧列出所有 Agent,点击右侧展示详情 +- **Daily Token Chart**:CSS 纯实现的堆叠柱状图(Input/Output 分色),无第三方图表库 +- **Tool Usage Table**:按工具名分组,展示 Calls / Tokens / Avg ms / Errors + +### 8.4 数据获取 + +使用轻量 React hooks(`useState` + `fetch`),不依赖 React Query: + +```typescript +// src/hooks/use-observability.ts +useIdeaLifecycleTokens(projectUuid, ideaUuid) // → IdeaLifecycleResult +useEntityTokens(projectUuid, entityType, entityUuid) // → EntityTokensResult +``` + +Agent Dashboard 页面使用 `useState` + `useEffect` 直接 fetch,支持 Agent 选择和时间范围切换。 + +### 8.5 Token 格式化 + +`src/lib/format-tokens.ts` 提供统一的 Token 显示格式: +- < 1000: 原值 ("420") +- 1K-999K: 保留一位小数 ("3.8K") +- ≥ 1M: 保留一位小数 ("1.2M") + +--- + +## 9. 三层对比 + +| 维度 | Layer 1(服务端 MCP) | Layer 2(CC Hook 聚合) | Layer 3(转录解析) | +|------|---------------------|----------------------|-------------------| +| 采集目标 | Chorus MCP 工具(60+) | CC 所有工具(Bash/Read/Write 等) | Session 级 Token 总量 | +| 触发方式 | 自动(registerTool 拦截) | 自动(PostToolUse hook async:true) | 自动(SubagentStop hook) | +| Agent 感知 | 完全无感 | 完全无感 | 完全无感 | +| 网络延迟 | 异步写 DB(~1ms) | 零(本地文件追加) | 结束时一次 HTTP 请求 | +| 实体关联 | 精确(参数直接提取) | Active Context 推断(~90%) | Session → Task Checkin | +| Token 数据 | 无(只有 I/O size) | 无(只有 I/O size) | 精确(input/output/cache) | +| 数据延迟 | 实时 | 准实时(TeammateIdle 周期) | Session 结束时 | +| 依赖 | Prisma + PostgreSQL | jq + Bash 3.2 | jq + CC 转录文件格式 | + +--- + +## 10. 文件清单 + +### 后端 + +| 文件 | 职责 | +|------|------| +| `prisma/schema.prisma` | ToolUsageEvent 模型 + AgentSession.tokenUsage 字段 | +| `src/mcp/tools/tool-logger.ts` | Layer 1:MCP 工具调用拦截 + 异步持久化 | +| `src/mcp/tools/presence.ts` | detectResource() + resolveProjectUuid() 实体关联 | +| `src/services/observability.service.ts` | 聚合查询服务(实体/生命周期/Agent 维度) | +| `src/services/session.service.ts` | updateTokenUsage() Token 累加合并 | +| `src/app/api/agent-report/tool-usage/route.ts` | Layer 2 批量上报端点 | +| `src/app/api/agent-report/token-usage/route.ts` | Layer 3 Token 上报端点 | +| `src/app/api/projects/[uuid]/observability/route.ts` | Agent 仪表盘查询 | +| `src/app/api/projects/[uuid]/observability/entity/route.ts` | 单实体查询 | +| `src/app/api/projects/[uuid]/observability/idea/[ideaUuid]/route.ts` | Idea 生命周期查询 | + +### CC 插件 + +| 文件 | 职责 | +|------|------| +| `public/chorus-plugin/hooks/hooks.json` | PostToolUse ".*" matcher 注册 | +| `public/chorus-plugin/bin/on-post-tool-log.sh` | Layer 2:本地 JSONL 采集 + Active Context | +| `public/chorus-plugin/bin/chorus-api.sh` | flush-tool-log 命令:原子移动 + 批量上传 | +| `public/chorus-plugin/bin/on-teammate-idle.sh` | TeammateIdle 时触发 flush | +| `public/chorus-plugin/bin/on-subagent-stop.sh` | SubagentStop 时 flush + Layer 3 转录解析 | + +### 前端 + +| 文件 | 职责 | +|------|------| +| `src/app/(dashboard)/projects/[uuid]/observability/page.tsx` | Agent 仪表盘入口(Server Component) | +| `src/app/(dashboard)/projects/[uuid]/observability/agent-observability.tsx` | 仪表盘主体 | +| `src/app/(dashboard)/projects/[uuid]/observability/daily-token-chart.tsx` | 日 Token 柱状图 | +| `src/app/(dashboard)/projects/[uuid]/observability/tool-usage-table.tsx` | 工具调用明细表 | +| `src/app/(dashboard)/projects/[uuid]/dashboard/panels/tokens-view.tsx` | Idea Tokens Tab | +| `src/app/(dashboard)/projects/[uuid]/tasks/task-tokens-view.tsx` | Task Tokens Tab | +| `src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/token-usage-card.tsx` | Proposal Token Card | +| `src/hooks/use-observability.ts` | React hooks for data fetching | +| `src/lib/format-tokens.ts` | Token 数量格式化工具 | + +### 测试 + +| 文件 | 覆盖 | +|------|------| +| `src/mcp/__tests__/tool-logger.test.ts` | Layer 1 持久化逻辑(19 tests) | +| `src/services/__tests__/observability.service.test.ts` | 聚合查询服务(25 tests) | + +--- + +## 11. 安全考虑 + +1. **多租户隔离**:所有查询都以 `companyUuid` 为前提条件,不存在跨公司数据泄露 +2. **Agent 上报端点隔离**:`/api/agent-report/*` 强制 `auth.type === "agent"`,用户 Token 无法调用 +3. **Session 归属验证**:上报 Tool Usage 和 Token Usage 时,验证 sessionUuid 属于当前 Agent +4. **批次大小限制**:单次上报上限 500 条事件,防止恶意大量写入 +5. **参数截断**:tool-logger 在日志中截断超过 500 字符的参数值,防止敏感数据泄露 + +--- + +## 12. 已知限制与未来改进 + +### 当前限制 + +1. **Layer 1/2 的 inputSize/outputSize 是 JSON 字节长度,不是 Token 数**:`JSON.stringify(params).length` 和 LLM token 完全不等价——不包含 thinking/reasoning 消耗,不包含系统 prompt 和对话历史的上下文 token,且字节数 ≠ token 数(1 token ≈ 4 bytes 英文,中文差异更大)。当前 I/O size 只能用于工具间的**相对比例对比**,绝对值没有意义。在没有 Layer 3 数据时,前端不应将 I/O size 显示为 "tokens"。 +2. **Layer 2 无 durationMs**:CC PostToolUse hook 不提供工具执行耗时,客户端上报事件的 durationMs 默认为 0 +3. **主 Agent Token 无法自动采集**:SubagentStop 只能获取 Sub-agent 的转录。主 Agent 的 Token 用量需要手动触发或等待 CC 支持 SessionEnd hook +4. **实时性**:Layer 2 是准实时(依赖 TeammateIdle 周期),不是逐条实时推送 +5. **Active Context 切换误差**:快速切换多个实体时,切换瞬间的几条调用可能归属到前一个实体 + +### 未来改进:用 Tokenizer 计算工具调用的实际 Token 数 + +当前 Layer 1/2 记录的 `inputSize`/`outputSize` 是 JSON 字节长度,无法作为 token 数展示。解决方案是在服务端引入轻量级 tokenizer,对工具参数和返回值进行真实 token 计数。 + +**候选 Tokenizer 库(纯 JS/WASM,跨平台):** + +| 包 | 大小 | 类型 | 周下载量 | 说明 | +|----|------|------|---------|------| +| **js-tiktoken** | 11 MB | 纯 JS | 430 万 | 推荐。零原生依赖,支持 cl100k_base(Claude 近似编码) | +| **gpt-tokenizer** | 55 MB | 纯 JS | 55 万 | 支持 o200k_base 等新编码,体积较大 | +| **tiktoken** | 5.4 MB WASM | WASM | 97 万 | 性能最好,但依赖 WASM runtime | +| **@anthropic-ai/tokenizer** | 1.4 MB | tiktoken 封装 | 极少 | Anthropic 官方但功能薄 | + +所有候选都满足跨平台要求(linux-x64/arm64, darwin-x64/arm64, Windows)。Claude 使用类似 `cl100k_base` 的编码,`js-tiktoken` 是最佳选择——纯 JS、零原生依赖、成熟稳定。 + +**集成方案(待实现):** +- 在 `tool-logger.ts` 的 `persistToolUsage` 中,用 tokenizer 计算 params/result 的 token 数,写入新字段 `inputTokens`/`outputTokens`(替代当前的字节长度 `inputSize`/`outputSize`,或新增字段并行存储) +- 需评估性能影响:每次 MCP 工具调用都跑 tokenizer 编码,可能增加 CPU 开销 + +### 其他未来改进方向 + +1. **实时 WebSocket 推送**:PostToolUse hook 中改用 WebSocket 直连,实现逐条实时展示 +2. **Token 消耗归因到实体**:将 Session Token 按工具调用的 token 数比例分摊到各实体 +3. **成本估算**:基于模型定价计算各维度的 USD 成本 +4. **告警阈值**:当单个 Task 或 Agent 的 Token 消耗超过阈值时自动通知 diff --git a/docs/design.pen b/docs/design.pen index adcb05b9..b7070a32 100644 --- a/docs/design.pen +++ b/docs/design.pen @@ -1,5 +1,5 @@ { - "version": "2.10", + "version": "2.11", "children": [ { "type": "frame", @@ -79606,6 +79606,4711 @@ ] } ] + }, + { + "type": "frame", + "id": "0wRPG", + "x": 0, + "y": 21661, + "name": "Observability - Agent Dashboard", + "width": 1440, + "height": 900, + "fill": "#FAF8F4", + "children": [ + { + "type": "frame", + "id": "yeKqE", + "name": "sidebar", + "width": 240, + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 48, + "padding": [ + 32, + 24 + ], + "justifyContent": "space_between", + "children": [ + { + "type": "frame", + "id": "gYSly", + "name": "Sidebar Top", + "width": "fill_container", + "layout": "vertical", + "gap": 48, + "children": [ + { + "type": "frame", + "id": "GAEtx", + "name": "Logo", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 4, + "id": "c2CWf", + "name": "logoMark2", + "fill": "#C67A52", + "width": 28, + "height": 28 + }, + { + "type": "text", + "id": "H9bPU", + "name": "logoText2", + "fill": "#2C2C2C", + "content": "Chorus", + "fontFamily": "IBM Plex Sans", + "fontSize": 18, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "otw6N", + "name": "Navigation", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "vdrgP", + "name": "Back to Projects", + "width": "fill_container", + "gap": 12, + "padding": [ + 10, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "lS6Hu", + "width": 12, + "height": 12, + "iconFontName": "arrow-left", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "ZW9fe", + "fill": "#6B6B6B", + "content": "Projects", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SGcw4", + "name": "Project Header", + "width": "fill_container", + "padding": [ + 12, + 0, + 6, + 0 + ], + "children": [ + { + "type": "text", + "id": "DOi9v", + "fill": "#2C2C2C", + "content": "Project Alpha", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 0.5 + } + ] + }, + { + "type": "frame", + "id": "VBNBs", + "name": "Nav Overview", + "width": "fill_container", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "9IQ1o", + "width": 16, + "height": 16, + "iconFontName": "layout-dashboard", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "VHZHz", + "fill": "#6B6B6B", + "content": "Overview", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "QHL9q", + "name": "Nav Ideas", + "width": "fill_container", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "XoYtA", + "width": 16, + "height": 16, + "iconFontName": "lightbulb", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "8pyGX", + "fill": "#6B6B6B", + "content": "Ideas", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SPY77", + "name": "Nav Documents", + "width": "fill_container", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "7DMOc", + "width": 16, + "height": 16, + "iconFontName": "file-text", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "5wkBK", + "fill": "#6B6B6B", + "content": "Documents", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SXNJ9", + "name": "Nav Proposals", + "width": "fill_container", + "fill": "#FFFFFF00", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "MzFXw", + "width": 16, + "height": 16, + "iconFontName": "clipboard-list", + "iconFontFamily": "lucide", + "fill": "#C67A52" + }, + { + "type": "text", + "id": "orAx7", + "fill": "#2C2C2C", + "content": "Proposals", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "7i5qB", + "name": "Nav Tasks", + "width": "fill_container", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "JTNUW", + "width": 16, + "height": 16, + "iconFontName": "check-square", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "uGT1X", + "fill": "#6B6B6B", + "content": "Tasks", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "l5q2l", + "name": "Nav Activity Active", + "width": "fill_container", + "fill": "#F5F2EC", + "cornerRadius": 8, + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "6uAI8", + "width": 16, + "height": 16, + "iconFontName": "activity", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "8bJ4f", + "fill": "#6B6B6B", + "content": "Activity", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OqkUJ", + "name": "Nav Knowledge", + "width": "fill_container", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "5h0Fi", + "width": 16, + "height": 16, + "iconFontName": "book-open", + "iconFontFamily": "lucide", + "fill": "#6B6B6B" + }, + { + "type": "text", + "id": "71rl4", + "fill": "#6B6B6B", + "content": "Knowledge", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Xt2Is", + "name": "Sidebar Bottom", + "width": "fill_container", + "layout": "vertical", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "tiCh2", + "name": "User Profile", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BcqoW", + "name": "avatar2", + "width": 36, + "height": 36, + "fill": "#C67A52", + "cornerRadius": 18, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Klfja", + "name": "avatarText2", + "fill": "#FFFFFF", + "content": "JD", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "IIzc7", + "name": "userInfo2", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "N8gQq", + "name": "userName2", + "fill": "#2C2C2C", + "content": "John Doe", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "I8qvm", + "name": "userRole2", + "fill": "#6B6B6B", + "content": "Tech Lead", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "fQVKV", + "name": "Main Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": [ + 32, + 40 + ], + "children": [ + { + "type": "frame", + "id": "j7iSx", + "name": "topbar", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "4aj9M", + "fill": "#9A9A9A", + "content": "Project Alpha / Observability", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "P89kp", + "name": "topActs", + "gap": 16, + "children": [ + { + "type": "icon_font", + "id": "KYmR7", + "width": 16, + "height": 16, + "iconFontName": "search", + "iconFontFamily": "lucide", + "fill": "#9A9A9A" + }, + { + "type": "icon_font", + "id": "nJMKd", + "width": 16, + "height": 16, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "#9A9A9A" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ObsQR", + "name": "titleRow", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "jrZ59", + "name": "titleLeft", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "qzGWa", + "fill": "#2C2C2C", + "content": "Agent Observability", + "fontFamily": "IBM Plex Sans", + "fontSize": 24, + "fontWeight": "600" + }, + { + "type": "text", + "id": "vwHP2", + "fill": "#6B6B6B", + "content": "Token usage and tool call metrics across all agents", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sdg1L", + "name": "rangeRow", + "cornerRadius": 8, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "children": [ + { + "type": "frame", + "id": "eTBu7", + "name": "rb1", + "fill": "#C67A52", + "cornerRadius": [ + 8, + 0, + 0, + 8 + ], + "padding": [ + 6, + 14 + ], + "children": [ + { + "type": "text", + "id": "wl64K", + "fill": "#FFFFFF", + "content": "7d", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "loczA", + "name": "rb2", + "stroke": { + "thickness": { + "right": 1, + "left": 1 + }, + "fill": "#E5E0D8" + }, + "padding": [ + 6, + 14 + ], + "children": [ + { + "type": "text", + "id": "KepwU", + "fill": "#6B6B6B", + "content": "30d", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "FJC6c", + "name": "rb3", + "cornerRadius": [ + 0, + 8, + 8, + 0 + ], + "padding": [ + 6, + 14 + ], + "children": [ + { + "type": "text", + "id": "kf9uv", + "fill": "#6B6B6B", + "content": "90d", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "031P1", + "name": "Stat Cards", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "W4fgS", + "name": "c1", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "text", + "id": "aG0bj", + "fill": "#9A9A9A", + "content": "Total Tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "7vR1t", + "name": "c1val", + "gap": 6, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "HvnoG", + "fill": "#2C2C2C", + "content": "2.4M", + "fontFamily": "IBM Plex Sans", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "XHxJB", + "fill": "#16a34a", + "content": "+12%", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "R6P3J", + "name": "c2", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "text", + "id": "GiJdb", + "fill": "#9A9A9A", + "content": "Tool Calls", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "WfBLC", + "name": "c2val", + "gap": 6, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "R8Ck6", + "fill": "#2C2C2C", + "content": "1,847", + "fontFamily": "IBM Plex Sans", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "UrGWZ", + "fill": "#9A9A9A", + "content": "264/day", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "7b9yS", + "name": "c3", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "text", + "id": "EMCo3", + "fill": "#9A9A9A", + "content": "Cache Read", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Nr0H5", + "name": "c3val", + "gap": 6, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "UyCvw", + "fill": "#2C2C2C", + "content": "1.6M", + "fontFamily": "IBM Plex Sans", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "8e6WJ", + "fill": "#16a34a", + "content": "+15%", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cAKwg", + "name": "c4", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "text", + "id": "Wcq2v", + "fill": "#9A9A9A", + "content": "Error Rate", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uZcx5", + "name": "c4val", + "gap": 6, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "ZQNmz", + "fill": "#2C2C2C", + "content": "2.1%", + "fontFamily": "IBM Plex Sans", + "fontSize": 22, + "fontWeight": "700" + }, + { + "type": "text", + "id": "wOmHS", + "fill": "#16a34a", + "content": "-0.5%", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "nHa8z", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "nGchZ", + "name": "Agent List", + "clip": true, + "width": 320, + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "jje7V", + "name": "aHdr", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 14, + 20 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mtnrL", + "fill": "#2C2C2C", + "content": "Agents", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "uRUwz", + "fill": "#9A9A9A", + "content": "3 online", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "T8OVZ", + "name": "a1", + "width": "fill_container", + "fill": "#F5F2EC", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 12, + "padding": [ + 12, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "YhuBg", + "fill": "#22C55E", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "TGnFu", + "name": "a1i", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "VbGur", + "fill": "#2C2C2C", + "content": "Admin Claude", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "F0ZpC", + "name": "a1sub", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5sD0p", + "fill": "#9A9A9A", + "content": "PM Agent", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "z7fx0", + "fill": "#9A9A9A", + "content": "·", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "fwlB1", + "fill": "#C67A52", + "content": "1.2M tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "mgy9R", + "name": "a2", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 12, + "padding": [ + 12, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "n0PDn", + "fill": "#22C55E", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "Utwks", + "name": "a2i", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "KI5qq", + "fill": "#2C2C2C", + "content": "Dev Worker #1", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "CLbze", + "name": "a2sub", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "pr96D", + "fill": "#9A9A9A", + "content": "Developer", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "PJRDP", + "fill": "#9A9A9A", + "content": "·", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "XQpUH", + "fill": "#9A9A9A", + "content": "820K tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "auEew", + "name": "a3", + "width": "fill_container", + "gap": 12, + "padding": [ + 12, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "mNtch", + "fill": "#9A9A9A", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "LTXBy", + "name": "a3i", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "LfYp3", + "fill": "#2C2C2C", + "content": "Dev Worker #2", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "xnGcj", + "name": "a3sub", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dL39G", + "fill": "#9A9A9A", + "content": "Developer", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Dnevm", + "fill": "#9A9A9A", + "content": "·", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "4YdYH", + "fill": "#9A9A9A", + "content": "380K tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "5Nc0s", + "name": "Detail Panel", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "Kaheb", + "name": "detHdr", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "5hhNF", + "name": "detTitle", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "dUhNt", + "fill": "#22C55E", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "Hs2Ro", + "fill": "#2C2C2C", + "content": "Admin Claude", + "fontFamily": "IBM Plex Sans", + "fontSize": 16, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "ZEEfr", + "name": "detBadge", + "fill": "#F5F2EC", + "cornerRadius": 6, + "padding": [ + 4, + 10 + ], + "children": [ + { + "type": "text", + "id": "4LpFg", + "fill": "#6B6B6B", + "content": "PM Agent", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "hZMNc", + "name": "Daily Tokens Chart", + "width": "fill_container", + "height": 200, + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 20 + ], + "children": [ + { + "type": "frame", + "id": "Wn751", + "name": "chartTitle", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WfFMF", + "fill": "#2C2C2C", + "content": "Daily Token Usage", + "fontFamily": "IBM Plex Sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "sZ9Gf", + "name": "legend", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "WdMt2", + "name": "leg1", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "XgQps", + "fill": "#C67A52", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "0Udxo", + "fill": "#9A9A9A", + "content": "Input", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "KHJiT", + "name": "leg2", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "Kfde9", + "fill": "#E5C8B5", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "EoQsp", + "fill": "#9A9A9A", + "content": "Output", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "BgXJU", + "name": "Bars", + "width": "fill_container", + "height": "fill_container", + "gap": 8, + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "Fa9uu", + "name": "b1", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Uvx0n", + "name": "b1s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "2nPft", + "fill": "#C67A52", + "width": "fill_container", + "height": 50 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "o7c9w", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 30 + } + ] + }, + { + "type": "text", + "id": "9d6zj", + "fill": "#9A9A9A", + "content": "13", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JlKh9", + "name": "b2", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "sFqZ8", + "name": "b2s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "ssWzr", + "fill": "#C67A52", + "width": "fill_container", + "height": 35 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "ehCHF", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 22 + } + ] + }, + { + "type": "text", + "id": "UVN7G", + "fill": "#9A9A9A", + "content": "14", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kIp1R", + "name": "b3", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "QYOyt", + "name": "b3s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "z1UHS", + "fill": "#C67A52", + "width": "fill_container", + "height": 70 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "xFtSA", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 40 + } + ] + }, + { + "type": "text", + "id": "f0tiU", + "fill": "#9A9A9A", + "content": "15", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Dl3fI", + "name": "b4", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "B2UiO", + "name": "b4s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "t25Fo", + "fill": "#C67A52", + "width": "fill_container", + "height": 45 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "UyG1k", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 28 + } + ] + }, + { + "type": "text", + "id": "904AK", + "fill": "#9A9A9A", + "content": "16", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "sINwC", + "name": "b5", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "vV7vj", + "name": "b5s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "f9iNa", + "fill": "#C67A52", + "width": "fill_container", + "height": 85 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "c7mLF", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 45 + } + ] + }, + { + "type": "text", + "id": "vFOBt", + "fill": "#9A9A9A", + "content": "17", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "jG2oP", + "name": "b6", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "cG40i", + "name": "b6s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "JZBoz", + "fill": "#C67A52", + "width": "fill_container", + "height": 60 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "fJjRb", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 35 + } + ] + }, + { + "type": "text", + "id": "w121u", + "fill": "#9A9A9A", + "content": "18", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JiQ6w", + "name": "b7", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 4, + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "iJVTJ", + "name": "b7s", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "rectangle", + "cornerRadius": [ + 4, + 4, + 0, + 0 + ], + "id": "KEp7I", + "fill": "#C67A52", + "width": "fill_container", + "height": 28 + }, + { + "type": "rectangle", + "cornerRadius": [ + 0, + 0, + 4, + 4 + ], + "id": "SOhqu", + "fill": "#E5C8B5", + "width": "fill_container", + "height": 16 + } + ] + }, + { + "type": "text", + "id": "zrED4", + "fill": "#9A9A9A", + "content": "Today", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "KEEj6", + "name": "Tool Usage Table", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "thickness": 1, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "BbKPI", + "name": "th", + "width": "fill_container", + "height": 40, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 0, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "XxWUo", + "name": "thc1", + "width": 180, + "children": [ + { + "type": "text", + "id": "QbZNb", + "fill": "#9A9A9A", + "content": "Tool", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "9OurR", + "name": "thc2", + "width": 60, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "Gbibn", + "fill": "#9A9A9A", + "content": "Calls", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "5pVvH", + "name": "thc3", + "width": 80, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "93wgZ", + "fill": "#9A9A9A", + "content": "Tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "BsdWA", + "name": "thc4", + "width": 70, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "2DWF7", + "fill": "#9A9A9A", + "content": "Avg ms", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "5O4ra", + "name": "thc5", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "wVpZr", + "fill": "#9A9A9A", + "content": "Errors", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Ure0e", + "name": "r1", + "width": "fill_container", + "height": 38, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 0, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HcZC4", + "name": "r1a", + "width": 180, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "h96vN", + "fill": "#C67A52", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "HirKR", + "fill": "#2C2C2C", + "content": "add_task_draft", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "9Flba", + "name": "r1b", + "width": 60, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "HWDJj", + "fill": "#2C2C2C", + "content": "312", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ERWva", + "name": "r1c", + "width": 80, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "fozFn", + "fill": "#C67A52", + "content": "420K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "aOZL1", + "name": "r1d", + "width": 70, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "R7ALf", + "fill": "#6B6B6B", + "content": "82", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "X4ItB", + "name": "r1e", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "4ZmkP", + "fill": "#DC2626", + "content": "3", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "POJZH", + "name": "r2", + "width": "fill_container", + "height": 38, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 0, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "A0ddv", + "name": "r2a", + "width": 180, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "uigBN", + "fill": "#C67A52", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "snENl", + "fill": "#2C2C2C", + "content": "report_work", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "A1LGF", + "name": "r2b", + "width": 60, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "KU9S2", + "fill": "#2C2C2C", + "content": "248", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "XSUXY", + "name": "r2c", + "width": 80, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "9w6QT", + "fill": "#C67A52", + "content": "310K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "pDYd3", + "name": "r2d", + "width": 70, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "N25FP", + "fill": "#6B6B6B", + "content": "45", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "NVeST", + "name": "r2e", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "mTsTz", + "fill": "#9A9A9A", + "content": "0", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "riKHr", + "name": "r3", + "width": "fill_container", + "height": 38, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 0, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Akefv", + "name": "r3a", + "width": 180, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "mYdJW", + "fill": "#9A9A9A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "XnXgY", + "fill": "#2C2C2C", + "content": "Bash", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kPXmI", + "name": "r3b", + "width": 60, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "Y0R95", + "fill": "#2C2C2C", + "content": "531", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ygh9x", + "name": "r3c", + "width": 80, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "qhQKS", + "fill": "#6B6B6B", + "content": "185K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "jXIUs", + "name": "r3d", + "width": 70, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "bHaSv", + "fill": "#9A9A9A", + "content": "—", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "egubs", + "name": "r3e", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "hECtX", + "fill": "#DC2626", + "content": "12", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "pbWXK", + "name": "r4", + "width": "fill_container", + "height": 38, + "padding": [ + 0, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "iipKC", + "name": "r4a", + "width": 180, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "CLWUp", + "fill": "#9A9A9A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "jys0r", + "fill": "#2C2C2C", + "content": "Read / Write / Edit", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "dVAWz", + "name": "r4b", + "width": 60, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "Y2JyR", + "fill": "#2C2C2C", + "content": "756", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "H6tv7", + "name": "r4c", + "width": 80, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "tZFyh", + "fill": "#6B6B6B", + "content": "112K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "xHzwq", + "name": "r4d", + "width": 70, + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "Wntfa", + "fill": "#9A9A9A", + "content": "—", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lFyF6", + "name": "r4e", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "text", + "id": "d7p4I", + "fill": "#9A9A9A", + "content": "0", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IY8HC", + "x": 0, + "y": 22761, + "name": "Token Tracking — Drill-down Panels", + "width": 1560, + "height": 940, + "fill": "#F7F6F3", + "gap": 30, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "iQ7d7", + "name": "① Idea Tokens Tab", + "clip": true, + "width": 460, + "height": 900, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 20 + ], + "children": [ + { + "type": "frame", + "id": "UeMYC", + "name": "ih", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "z93WV", + "fill": "#2C2C2C", + "content": "Add observability to Chorus", + "fontFamily": "IBM Plex Sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "zpwhy", + "name": "ihSub", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "lX4fr", + "name": "ib1", + "fill": "#DCFCE7", + "cornerRadius": 4, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "f7eS8", + "fill": "#16a34a", + "content": "Delivered", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "nydsO", + "fill": "#9A9A9A", + "content": "3 days", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "fLK9G", + "name": "itabs", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "children": [ + { + "type": "frame", + "id": "6AGe0", + "name": "it1", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "LkM6P", + "fill": "#9A9A9A", + "content": "Overview", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "LcxR6", + "name": "it2", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "ExsJJ", + "fill": "#9A9A9A", + "content": "Tasks", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "0ByY9", + "name": "it3", + "stroke": { + "thickness": { + "bottom": 2 + }, + "fill": "#C67A52" + }, + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "geIUX", + "fill": "#C67A52", + "content": "Tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "wDt2e", + "name": "it4", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "XjJem", + "fill": "#9A9A9A", + "content": "Activity", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "E1gk0", + "name": "totalCard", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 12, + 16 + ], + "justifyContent": "space_between", + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "nAVy6", + "name": "tcLeft", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "6LfyC", + "fill": "#9A9A9A", + "content": "Total (input + output)", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "hv4ou", + "fill": "#2C2C2C", + "content": "4.8M tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "KdDTb", + "name": "tcRight", + "layout": "vertical", + "gap": 2, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "V9KNT", + "fill": "#9A9A9A", + "content": "Cache read", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "YqhUZ", + "fill": "#22C55E", + "content": "3.1M", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "EiZ2Z", + "name": "secA", + "fill": "#9A9A9A", + "content": "LIFECYCLE", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "6zERQ", + "name": "lcList", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "aEnPJ", + "name": "lc1", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 9, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "3xQE1", + "fill": "#E0F2F1", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "6kvKO", + "name": "lc1m", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5867M", + "fill": "#2C2C2C", + "content": "Elaboration", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ixfPx", + "fill": "#C67A52", + "content": "180K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "5tQbE", + "name": "lc2", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 9, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "JnFLG", + "fill": "#E0F2F1", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "vcysR", + "name": "lc2m", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Jn1HF", + "name": "lc2lbl", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XfDkZ", + "fill": "#2C2C2C", + "content": "Proposal", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "jgFnW", + "width": 12, + "height": 12, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#C67A52" + } + ] + }, + { + "type": "text", + "id": "lBl8A", + "fill": "#C67A52", + "content": "620K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "5Bpz7", + "name": "lc3", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 9, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "0oRlZ", + "fill": "#E0F2F1", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "wub9F", + "name": "lc3m", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Z6J1r", + "name": "lc3lbl", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "8IuyU", + "fill": "#2C2C2C", + "content": "Review (2 rounds)", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "G5DME", + "width": 12, + "height": 12, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#C67A52" + } + ] + }, + { + "type": "text", + "id": "phdnx", + "fill": "#C67A52", + "content": "320K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xXnxc", + "name": "lc4", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "padding": [ + 9, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "BVjRD", + "fill": "#C67A52", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "GloF0", + "name": "lc4m", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Rd9pc", + "fill": "#2C2C2C", + "content": "Execution", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "UrMWd", + "fill": "#C67A52", + "content": "3.2M", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "9SCxf", + "name": "lc5", + "width": "fill_container", + "padding": [ + 9, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Ol6Au", + "fill": "#22C55E", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "Kel4w", + "name": "lc5m", + "width": "fill_container", + "padding": [ + 0, + 0, + 0, + 8 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "LrUpa", + "fill": "#2C2C2C", + "content": "Verify", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Ug3Vr", + "fill": "#C67A52", + "content": "460K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "ASKL2", + "name": "secB", + "fill": "#9A9A9A", + "content": "TASKS", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "70hNh", + "name": "tkList", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "3CYp8", + "name": "tk1", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "WRklP", + "fill": "#22C55E", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "dOLmF", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Extend tool-logger.ts", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "1ZORc", + "fill": "#C67A52", + "content": "1.4M", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "F2DXh", + "width": 12, + "height": 12, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#D9D9D9" + } + ] + }, + { + "type": "frame", + "id": "DZPlB", + "name": "tk2", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "oiCi6", + "fill": "#22C55E", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "mZDkZ", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Add PostToolUse hook", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "l5JDP", + "fill": "#C67A52", + "content": "980K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "zCobr", + "width": 12, + "height": 12, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#D9D9D9" + } + ] + }, + { + "type": "frame", + "id": "Syilx", + "name": "tk3", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "PHUPp", + "fill": "#F59E0B", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "bAlKq", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Parse transcript in SubagentStop", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "PCV9A", + "fill": "#C67A52", + "content": "520K", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "dVIvp", + "width": 12, + "height": 12, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#D9D9D9" + } + ] + }, + { + "type": "frame", + "id": "KZfNX", + "name": "tk4", + "width": "fill_container", + "gap": 6, + "padding": [ + 8, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "IpDfR", + "fill": "#9A9A9A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "Yy4ip", + "fill": "#9A9A9A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Build dashboard UI", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ltI4x", + "fill": "#D9D9D9", + "content": "—", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "ubmgd", + "width": 12, + "height": 12, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#D9D9D9" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "SeMl5", + "name": "arr1", + "width": 20, + "height": 900, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VRwZQ", + "width": 20, + "height": 20, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#C67A52" + } + ] + }, + { + "type": "frame", + "id": "5kIqm", + "name": "② Proposal Tokens Tab", + "clip": true, + "width": 460, + "height": 900, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 20 + ], + "children": [ + { + "type": "frame", + "id": "EXz1N", + "name": "ph", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "4V32v", + "fill": "#2C2C2C", + "content": "PRD: Observability MVP", + "fontFamily": "IBM Plex Sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "xCIUQ", + "name": "phSub", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "32wvf", + "name": "pb", + "fill": "#DCFCE7", + "cornerRadius": 4, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "As9O0", + "fill": "#16a34a", + "content": "Approved", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "AfE8m", + "fill": "#9A9A9A", + "content": "Admin Claude", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vLLqU", + "name": "ptabs", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "children": [ + { + "type": "frame", + "id": "4z99e", + "name": "pt1", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "hefFv", + "fill": "#9A9A9A", + "content": "Docs", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "LmaCv", + "name": "pt2", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "WoWGM", + "fill": "#9A9A9A", + "content": "Tasks", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "UjOfv", + "name": "pt3", + "stroke": { + "thickness": { + "bottom": 2 + }, + "fill": "#C67A52" + }, + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "qcQH1", + "fill": "#C67A52", + "content": "Tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "ztqAa", + "name": "pt4", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "ypcVg", + "fill": "#9A9A9A", + "content": "Comments", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "bdIA2", + "name": "pTotal", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 12, + 16 + ], + "justifyContent": "space_between", + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "vyv1L", + "name": "ptLeft", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "1SxU0", + "fill": "#9A9A9A", + "content": "Total (input + output)", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "hqEFs", + "fill": "#2C2C2C", + "content": "620K tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "R3UGb", + "name": "ptRight", + "layout": "vertical", + "gap": 2, + "alignItems": "end", + "children": [ + { + "type": "text", + "id": "1EvRL", + "fill": "#9A9A9A", + "content": "2 review rounds", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "c3xm7", + "fill": "#6B6B6B", + "content": "100K", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "dXMaM", + "name": "psecA", + "fill": "#9A9A9A", + "content": "DRAFTING", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "h2iH0", + "name": "dbar", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "oJgQm", + "name": "barRow", + "clip": true, + "width": "fill_container", + "height": 8, + "cornerRadius": 4, + "gap": 2, + "children": [ + { + "type": "rectangle", + "id": "SghXH", + "fill": "#C67A52", + "width": 200, + "height": "fill_container" + }, + { + "type": "rectangle", + "id": "m7FaM", + "fill": "#E5C8B5", + "width": 140, + "height": "fill_container" + }, + { + "type": "rectangle", + "id": "ZuAX9", + "fill": "#F0EDE8", + "width": "fill_container", + "height": "fill_container" + } + ] + }, + { + "type": "frame", + "id": "IjHlS", + "name": "barLeg", + "width": "fill_container", + "gap": 14, + "children": [ + { + "type": "frame", + "id": "ozaNW", + "name": "bl1", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 1, + "id": "B9ReH", + "fill": "#C67A52", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "Lx4df", + "fill": "#6B6B6B", + "content": "Docs 280K", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CnYCB", + "name": "bl2", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 1, + "id": "pnKFl", + "fill": "#E5C8B5", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "Zj5ax", + "fill": "#6B6B6B", + "content": "Tasks 190K", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SFGAd", + "name": "bl3", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 1, + "id": "JAclp", + "fill": "#F0EDE8", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "hue6r", + "fill": "#6B6B6B", + "content": "Validate 50K", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "tQYdJ", + "name": "psecB", + "fill": "#9A9A9A", + "content": "REVIEWS", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "DQ8D8", + "name": "revList", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "frame", + "id": "Mtjoc", + "name": "rv1", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "1DUcN", + "name": "rv1l", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "yPvWk", + "fill": "#2C2C2C", + "content": "Round 1", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "pWBCE", + "name": "rvb1", + "fill": "#FEE2E2", + "cornerRadius": 3, + "padding": [ + 1, + 6 + ], + "children": [ + { + "type": "text", + "id": "0I72V", + "fill": "#DC2626", + "content": "FAIL", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "KEcjP", + "fill": "#9A9A9A", + "content": "48K", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "u33o3", + "name": "rv2", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 8, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "4V2j2", + "name": "rv2l", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FgC5Z", + "fill": "#2C2C2C", + "content": "Round 2", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "Pk2vW", + "name": "rvb2", + "fill": "#DCFCE7", + "cornerRadius": 3, + "padding": [ + 1, + 6 + ], + "children": [ + { + "type": "text", + "id": "2MpJE", + "fill": "#16a34a", + "content": "PASS", + "fontFamily": "IBM Plex Sans", + "fontSize": 9, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "BHLbt", + "fill": "#9A9A9A", + "content": "52K", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "AvfGT", + "name": "psecC", + "fill": "#9A9A9A", + "content": "TOP TOOLS", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "Z1yyk", + "name": "toolList", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "Bv7u9", + "name": "tl1", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "Jga7l", + "name": "tl1h", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "IcTiL", + "fill": "#2C2C2C", + "content": "pm_add_document_draft", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KJzKR", + "fill": "#C67A52", + "content": "180K", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "j60wz", + "name": "tl1b", + "width": "fill_container", + "height": 3, + "fill": "#F0EDE8", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "NH8iT", + "fill": "#C67A52", + "width": 260, + "height": "fill_container" + } + ] + } + ] + }, + { + "type": "frame", + "id": "wkjtF", + "name": "tl2", + "width": "fill_container", + "layout": "vertical", + "gap": 3, + "children": [ + { + "type": "frame", + "id": "6CAH8", + "name": "tl2h", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "6DTlL", + "fill": "#2C2C2C", + "content": "pm_add_task_draft", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KjBbB", + "fill": "#C67A52", + "content": "120K", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "N4KVD", + "name": "tl2b", + "width": "fill_container", + "height": 3, + "fill": "#F0EDE8", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "ykIx9", + "fill": "#C67A52", + "width": 170, + "height": "fill_container" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "BRMSM", + "name": "arr2", + "width": 20, + "height": 900, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zeh3Y", + "width": 20, + "height": 20, + "iconFontName": "chevron-right", + "iconFontFamily": "lucide", + "fill": "#C67A52" + } + ] + }, + { + "type": "frame", + "id": "WgTnQ", + "name": "③ Task Tokens Tab", + "clip": true, + "width": 460, + "height": 900, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#E5E0D8" + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 20 + ], + "children": [ + { + "type": "frame", + "id": "1VEcM", + "name": "th", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "c4csF", + "fill": "#2C2C2C", + "content": "Extend tool-logger.ts", + "fontFamily": "IBM Plex Sans", + "fontSize": 15, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "VlwTX", + "name": "thSub", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "aASG5", + "name": "tbg", + "fill": "#DCFCE7", + "cornerRadius": 4, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "s6bPp", + "fill": "#16a34a", + "content": "Done", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "Psuq9", + "fill": "#9A9A9A", + "content": "Dev Worker #1 · 42 min", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "VWiKl", + "name": "ttabs", + "width": "fill_container", + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "children": [ + { + "type": "frame", + "id": "TfbGH", + "name": "tt1", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "SfYsb", + "fill": "#9A9A9A", + "content": "Detail", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "oPPoW", + "name": "tt2", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "iG5cO", + "fill": "#9A9A9A", + "content": "AC", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "0WHlC", + "name": "tt3", + "stroke": { + "thickness": { + "bottom": 2 + }, + "fill": "#C67A52" + }, + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "SQjIA", + "fill": "#C67A52", + "content": "Tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "6B8ur", + "name": "tt4", + "padding": [ + 0, + 10, + 7, + 0 + ], + "children": [ + { + "type": "text", + "id": "U7bDb", + "fill": "#9A9A9A", + "content": "Activity", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "text", + "id": "FEe40", + "name": "tsecA", + "fill": "#9A9A9A", + "content": "TOKEN BREAKDOWN", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "jqHJx", + "name": "tStats", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "sOMWo", + "name": "ts1", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GuKmh", + "fill": "#6B6B6B", + "content": "Input tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "I6lIi", + "fill": "#2C2C2C", + "content": "920K", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "GvQMC", + "name": "ts2", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qHCn6", + "fill": "#6B6B6B", + "content": "Output tokens", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "5z94R", + "fill": "#2C2C2C", + "content": "480K", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "67qyy", + "name": "ts3", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WmLef", + "fill": "#6B6B6B", + "content": "Cache read", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "UBdk9", + "name": "ts3r", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "H9Lsw", + "fill": "#22C55E", + "content": "625K", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "JJKg6", + "fill": "#22C55E", + "content": "68%", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "z9c3Y", + "name": "ts4", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "T86Fc", + "fill": "#6B6B6B", + "content": "Cache write", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "3RYlS", + "fill": "#2C2C2C", + "content": "84K", + "fontFamily": "IBM Plex Sans", + "fontSize": 14, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "text", + "id": "6kUt7", + "name": "tsecB", + "fill": "#9A9A9A", + "content": "SESSION", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "n5ujI", + "name": "sessRow", + "width": "fill_container", + "fill": "#F7F6F3", + "cornerRadius": 8, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "cirxi", + "fill": "#22C55E", + "width": 8, + "height": 8 + }, + { + "type": "frame", + "id": "fVWvK", + "name": "sessI", + "width": "fill_container", + "layout": "vertical", + "gap": 1, + "children": [ + { + "type": "text", + "id": "3XAYs", + "fill": "#2C2C2C", + "content": "frontend-worker", + "fontFamily": "IBM Plex Sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "pbSe7", + "fill": "#9A9A9A", + "content": "186 tool calls · 24 MCP + 162 CC", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "text", + "id": "uPrAk", + "name": "tsecC", + "fill": "#9A9A9A", + "content": "TOOL CALLS", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "sIHEr", + "name": "legRow", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "VlQ6V", + "name": "lg1", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 1, + "id": "v8JQT", + "fill": "#C67A52", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "s8gb5", + "fill": "#9A9A9A", + "content": "MCP", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "EG1Nn", + "name": "lg2", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 1, + "id": "WRPmf", + "fill": "#E5C8B5", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "RpZmF", + "fill": "#9A9A9A", + "content": "Bash", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "guc1i", + "name": "lg3", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "rectangle", + "cornerRadius": 1, + "id": "gmxJe", + "fill": "#9A9A9A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "qu7D6", + "fill": "#9A9A9A", + "content": "Read/Write", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qy22y", + "name": "tl", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "zIuEf", + "name": "e1", + "width": "fill_container", + "height": 24, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5k3Yy", + "fill": "#9A9A9A", + "content": "14:02", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "BklHn", + "fill": "#C67A52", + "width": 3, + "height": 12 + }, + { + "type": "text", + "id": "fhlpQ", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "claim_task", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Sehd9", + "fill": "#9A9A9A", + "content": "45ms", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "amkel", + "name": "e2", + "width": "fill_container", + "height": 24, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ms31M", + "fill": "#9A9A9A", + "content": "14:03", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "VSCtc", + "fill": "#9A9A9A", + "width": 3, + "height": 12 + }, + { + "type": "text", + "id": "dcJVh", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Read tool-logger.ts", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FaZfB", + "name": "e3", + "width": "fill_container", + "height": 24, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MC8Kf", + "fill": "#9A9A9A", + "content": "14:05", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "6A6Cy", + "fill": "#E5C8B5", + "width": 3, + "height": 12 + }, + { + "type": "text", + "id": "J3PuZ", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Bash prisma generate", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "t7P2n", + "name": "e4", + "width": "fill_container", + "height": 24, + "stroke": { + "thickness": { + "bottom": 1 + }, + "fill": "#F5F2EC" + }, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "DdMzb", + "fill": "#9A9A9A", + "content": "14:08", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "slRKf", + "fill": "#9A9A9A", + "width": 3, + "height": 12 + }, + { + "type": "text", + "id": "FKMJR", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Edit tool-logger.ts", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Kv4j5", + "name": "e5", + "width": "fill_container", + "height": 24, + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "edwXi", + "fill": "#9A9A9A", + "content": "14:12", + "fontFamily": "IBM Plex Mono", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "rectangle", + "cornerRadius": 2, + "id": "4DdLF", + "fill": "#C67A52", + "width": 3, + "height": 12 + }, + { + "type": "text", + "id": "G0Pii", + "fill": "#2C2C2C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "report_work", + "fontFamily": "IBM Plex Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iMUjS", + "fill": "#9A9A9A", + "content": "52ms", + "fontFamily": "IBM Plex Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] } ], "themes": { diff --git a/docs/token-observability-bugfix-checklist.md b/docs/token-observability-bugfix-checklist.md new file mode 100644 index 00000000..2687ab48 --- /dev/null +++ b/docs/token-observability-bugfix-checklist.md @@ -0,0 +1,61 @@ +# Token Observability — Bug Fix Checklist + +## Root Cause: Claude API usage fields have mixed semantics + +Claude API transcript `message.usage` per turn: +- `input_tokens` — per-turn (incremental) +- `output_tokens` — per-turn (incremental) +- `cache_creation_input_tokens` — per-turn (incremental) +- `cache_read_input_tokens` — **cumulative** across the session + +CC calculates `total_tokens = input + output + cache_create + cache_read` from the **last turn only**, because cache_read in the last turn already contains the full session total. + +The old code sent ALL turns and summed them → cache_read was double-counted N times (once per turn). Front-end only showed `input + output`, hiding the real magnitude. + +## Bug #1: Entity attribution — carry-forward wrong for sub-agents + +**Symptom**: Reviewer tokens attributed to elaboration (idea entity) instead of review (proposal entity). + +**Root cause**: carry-forward picks the last timeline entry before each turn. Reviewer reads idea for context before touching proposal → early turns attributed to idea. For sub-agents, ALL tokens belong to one primary entity regardless of which other entities they read. + +**Fix**: `findPrimaryEntity()` picks highest-priority entity from timeline (task > proposal > idea > document). Sub-agent turns all go to that entity. Main agent still uses carry-forward. + +- [x] `src/services/observability.service.ts` — replace sentinel with primary entity model +- [x] `src/services/__tests__/token-attribution.test.ts` — 16 tests covering both models +- [x] Verify: `pnpm test` — all pass + +## Bug #2: Shell scripts send all turns → cumulative cache_read over-counted + +**Symptom**: Token totals are 10-50x higher than CC reports. + +**Root cause**: All three shell scripts (`on-stop.sh`, `on-subagent-stop.sh`, `on-session-end.sh`) extracted every assistant turn's usage and sent them as separate records. Server summed all records. Since `cache_read_input_tokens` is cumulative, summing N turns counts the same cache tokens N times. + +**Fix**: Extract only the **last assistant turn** (which contains session totals). Also switch to temp files + `--slurpfile` + `curl -d @file` in all scripts. + +- [x] `public/chorus-plugin/bin/on-stop.sh` — last turn only +- [x] `public/chorus-plugin/bin/on-subagent-stop.sh` — last turn + temp files + sourceSessionId +- [x] `public/chorus-plugin/bin/on-session-end.sh` — last turn + temp files + sourceSessionId +- [x] Verify: `bash public/chorus-plugin/bin/test-syntax.sh` — all pass + +## Bug #3: Frontend tokensSum missing cache fields + +**Symptom**: Page shows ~1.9k when CC reports ~23k. + +**Root cause**: `tokensSum()` only summed `input_tokens + output_tokens`, missing `cache_creation_input_tokens` and `cache_read_input_tokens`. CC's formula includes all 4 fields. + +**Fix**: `tokensSum = input + output + cache_create + cache_read` in all 4 components. + +- [x] `tokens-view.tsx` — fixed +- [x] `agent-observability.tsx` — fixed +- [x] `task-tokens-view.tsx` — fixed +- [x] `token-usage-card.tsx` — fixed +- [x] Verify: `npx tsc --noEmit` — clean + +## Verification + +With a fresh CC session + new project: +1. Run full yolo pipeline (idea → proposal → reviewer → approve → dev → verify) +2. Check observability page: total tokens should match CC's reported total_tokens +3. Review phase should show reviewer's tokens (~23k), not 0 or 3.6k +4. Execution phase should show dev's tokens (~34k) +5. No tokens should leak into elaboration phase from reviewers diff --git a/docs/token-observability-changes.md b/docs/token-observability-changes.md new file mode 100644 index 00000000..9866f071 --- /dev/null +++ b/docs/token-observability-changes.md @@ -0,0 +1,89 @@ +# Token Observability Changes Summary + +## Architecture Overview + +Token usage is tracked per-assistant-turn via `TokenUsageRecord` table (decoupled from AgentSession). The CC Stop hook fires every assistant turn, uploading the full transcript turns + entity timeline to the server. Server does attribution and dedup. + +## Changed Files + +### Shell Scripts (Plugin) + +**`public/chorus-plugin/bin/on-stop.sh`** +- Stop hook fires every assistant turn (async) +- Extracts turns (per-assistant-message usage with timestamp) from transcript +- **NEW**: Extracts entity timeline from transcript's MCP tool_use blocks (was: tool-log.jsonl) +- Builds payload via temp files + jq --slurpfile (avoids shell arg length limits) +- POSTs to `/api/agent-report/token-usage` with `sourceSessionId` for dedup + +**`public/chorus-plugin/bin/on-subagent-stop.sh`** +- Layer 3 added: parses sub-agent transcript for turns + timeline, POSTs to server +- **NEW**: Extracts entity timeline from sub-agent's own transcript (was: tool-log.jsonl filtered by agent_id) +- Passes `sessionUuid` (Chorus session) so server can distinguish sub-agent vs main agent records + +**`public/chorus-plugin/bin/on-session-end.sh`** +- Same transcript-based timeline extraction as on-stop.sh +- Final upload on session close + +**`public/chorus-plugin/hooks/hooks.json`** +- Added Stop hook entry with nested format: `{matcher: "", hooks: [{type, command, async: true}]}` + +### Server (API + Service) + +**`src/app/api/agent-report/token-usage/route.ts`** +- Accepts `{sourceSessionId?, sessionUuid?, turns[], timeline[]}` +- Calls `attributeTokenUsage()` for per-turn entity attribution +- **NEW**: Calls `resolveProjectUuids()` (plural) for per-record projectUuid resolution +- Each record gets projectUuid based on its own entityUuid, not a single shared value + +**`src/services/observability.service.ts`** + +Key functions: +- `attributeTokenUsage()` — per-turn records with timeline entity matching via `findActiveEntity()` (carry-forward: last timeline entry before turn timestamp) +- **NEW**: `resolveProjectUuids()` — batch-queries all entity UUIDs, returns Map. Replaces old `resolveProjectUuid()` which returned a single value for all records +- `insertAttributedTokenUsage()` — uses `prisma.createMany` with `skipDuplicates` on `(sourceSessionId, turnTimestamp)` unique constraint +- `getIdeaLifecycleTokens()` — **NEW phase token logic**: uses `sessionUuid` to split tokens between phases: + - proposal entity + sessionUuid → review (sub-agent reviewer) + - proposal entity + no sessionUuid → proposal drafting (main agent) + - task entity + sessionUuid → execution (sub-agent dev) + - task entity + no sessionUuid → verify (main agent admin) + - idea entity → elaboration + +### Database + +**`prisma/schema.prisma`** +- Added `TokenUsageRecord` model with `@@unique([sourceSessionId, turnTimestamp])` for dedup +- Removed `tokenUsage` JSON field from `AgentSession` + +## Key Design Decisions + +1. **Timeline from transcript, not tool-log.jsonl** — Each agent's transcript is independent. tool-log.jsonl is shared and mixes agents. Transcript has MCP tool_use blocks with entity UUIDs in input params. + +2. **Per-record projectUuid** — A single CC session may span multiple projects. Each record's entityUuid resolves to its own projectUuid. Records without entity get null projectUuid. + +3. **sessionUuid distinguishes main agent vs sub-agent** — Sub-agents have Chorus sessions (sessionUuid set). Main agent records have sessionUuid null. This is used for phase attribution in lifecycle views. + +4. **Server-side dedup** — Stop hook uploads full transcript every turn. `skipDuplicates` on `(sourceSessionId, turnTimestamp)` prevents re-insertion. Only new turns get inserted. + +5. **Server resolves projectUuid** — Client doesn't need to track project. Server looks up entity → project via DB (task→proposal→project, idea→project, proposal→project). + +## Known Limitations + +- **Long CC sessions across projects**: The carry-forward entity attribution means turns between entity tool calls inherit the last entity. In a single-project session this is correct. In a multi-project session, turns after switching projects but before the first entity tool call in the new project may still be attributed to the previous project's entity. +- **Stale data from old uploads**: Records inserted by older code versions (single projectUuid, tool-log.jsonl timeline) remain in the DB. They are dedup-protected and won't be overwritten. For clean testing, use a brand new project in a fresh CC session. + +## Testing Checklist + +To verify E2E in a **new CC session** (important — avoids stale data): + +1. Create a new project +2. Create idea, claim, skip elaboration (generates idea entity timeline entries) +3. Create proposal with doc + task drafts, submit (generates proposal entity entries) +4. Spawn proposal-reviewer sub-agent (generates reviewer token upload via on-subagent-stop) +5. Approve proposal (materializes tasks) +6. Spawn dev sub-agent to execute task (generates task entity + dev token upload) +7. Verify task as admin +8. Check observability page: project total should be reasonable (not millions) +9. Check idea detail → Tokens tab: lifecycle phases should have distinct non-zero values +10. Check proposal detail: Total Tokens and Tool Calls should reflect actual work +11. Verify Review phase shows reviewer's tokens (non-zero), separate from Proposal drafting +12. Verify Execution phase shows dev's tokens, separate from Verify phase diff --git a/messages/en.json b/messages/en.json index 002c5177..cc5735b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -132,6 +132,7 @@ "proposals": "Proposals", "tasks": "Tasks", "activity": "Activity", + "observability": "Observability", "logout": "Sign out", "newProject": "New Project", "backToProjects": "Projects", @@ -1126,6 +1127,7 @@ "elaboration": "Elaboration", "proposal": "Proposal", "tasks": "Tasks", + "tokens": "Tokens", "activity": "Activity" }, "timeline": { @@ -1192,5 +1194,70 @@ "project": "Project", "project_group": "Group" } + }, + "observability": { + "title": "Agent Observability", + "subtitle": "Token usage and tool call metrics across all agents", + "range7d": "7d", + "range30d": "30d", + "range90d": "90d", + "totalTokens": "Total Tokens", + "toolCalls": "Tool Calls", + "sessions": "Sessions", + "cacheRead": "Cache Read", + "cacheWrite": "Cache write", + "outputTokens": "Output", + "errorRate": "Error Rate", + "callsPerDay": "{count}/day", + "input": "Input", + "output": "Output", + "agents": "Agents", + "agentsCount": "{count} total", + "onlineCount": "{count} online", + "tokensSuffix": "tokens", + "dailyTokenUsage": "Daily Token Usage", + "legendInput": "Input", + "legendOutput": "Output", + "today": "Today", + "toolColumn": "Tool", + "callsColumn": "Calls", + "tokensColumn": "Tokens", + "avgMsColumn": "Avg ms", + "errorsColumn": "Errors", + "noData": "No agent activity yet", + "noDataDesc": "Tool usage data will appear once agents start working on this project.", + "noToolData": "No tool usage in this range", + "selectAgent": "Select an agent", + "selectAgentDesc": "Pick an agent on the left to see their daily tokens and tool usage.", + "loading": "Loading...", + "loadError": "Failed to load observability data", + "rolePm": "PM Agent", + "roleDeveloper": "Developer", + "roleAdmin": "Admin", + "agent": "Agent", + "loadFailed": "Failed to load token usage", + "lifecycle": "Lifecycle breakdown", + "phase": { + "elaboration": "Elaboration", + "proposal": "Proposal drafting", + "review": "Review", + "execution": "Execution", + "verify": "Verify" + }, + "taskList": "Per-task usage", + "noTasks": "No tasks spawned from this idea yet", + "drafting": "Drafting", + "draftingBreakdown": "Drafting breakdown", + "draftingDocs": "Docs", + "draftingTasks": "Tasks", + "draftingValidate": "Validate", + "reviewRounds": "Review rounds", + "reviewPass": "PASS", + "reviewFail": "FAIL", + "toolTimeline": "Tool calls", + "toolCall": "{count, plural, one {# call} other {# calls}}", + "toolErrors": "{count, plural, one {# error} other {# errors}}", + "errors": "Errors", + "sessionInfo": "Session info" } } diff --git a/messages/zh.json b/messages/zh.json index 02fa60bf..d67e955a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -132,6 +132,7 @@ "proposals": "提案", "tasks": "任务", "activity": "动态", + "observability": "可观测性", "logout": "退出登录", "newProject": "新建项目", "backToProjects": "项目", @@ -1127,6 +1128,7 @@ "elaboration": "需求细化", "proposal": "提案", "tasks": "任务", + "tokens": "Tokens", "activity": "动态" }, "timeline": { @@ -1193,5 +1195,70 @@ "project": "项目", "project_group": "分组" } + }, + "observability": { + "title": "智能体可观测性", + "subtitle": "所有智能体的 Token 使用与工具调用指标", + "range7d": "7 天", + "range30d": "30 天", + "range90d": "90 天", + "totalTokens": "Token 总量", + "toolCalls": "工具调用", + "sessions": "会话", + "cacheRead": "缓存读取", + "cacheWrite": "缓存写入", + "outputTokens": "输出", + "errorRate": "错误率", + "callsPerDay": "{count}/天", + "input": "输入", + "output": "输出", + "agents": "智能体", + "agentsCount": "共 {count} 个", + "onlineCount": "{count} 个在线", + "tokensSuffix": "tokens", + "dailyTokenUsage": "每日 Token 使用", + "legendInput": "输入", + "legendOutput": "输出", + "today": "今日", + "toolColumn": "工具", + "callsColumn": "调用数", + "tokensColumn": "Token", + "avgMsColumn": "平均耗时", + "errorsColumn": "错误", + "noData": "暂无智能体活动", + "noDataDesc": "当智能体开始在此项目上工作后,工具使用数据将在此显示。", + "noToolData": "此区间内暂无工具使用", + "selectAgent": "选择智能体", + "selectAgentDesc": "在左侧选择一个智能体以查看其每日 Token 与工具使用情况。", + "loading": "加载中...", + "loadError": "加载可观测性数据失败", + "rolePm": "PM 智能体", + "roleDeveloper": "开发者", + "roleAdmin": "管理员", + "agent": "智能体", + "loadFailed": "加载 token 使用记录失败", + "lifecycle": "生命周期分布", + "phase": { + "elaboration": "需求细化", + "proposal": "提案起草", + "review": "评审", + "execution": "执行", + "verify": "验证" + }, + "taskList": "按任务分布", + "noTasks": "该想法尚未派生任何任务", + "drafting": "起草", + "draftingBreakdown": "起草阶段分布", + "draftingDocs": "文档", + "draftingTasks": "任务", + "draftingValidate": "校验", + "reviewRounds": "评审轮次", + "reviewPass": "通过", + "reviewFail": "未通过", + "toolTimeline": "工具调用", + "toolCall": "{count} 次调用", + "toolErrors": "{count} 个错误", + "errors": "错误", + "sessionInfo": "会话信息" } } diff --git a/prisma/migrations/20260419112021_add_tool_usage_event_and_token_usage/migration.sql b/prisma/migrations/20260419112021_add_tool_usage_event_and_token_usage/migration.sql new file mode 100644 index 00000000..16e84e83 --- /dev/null +++ b/prisma/migrations/20260419112021_add_tool_usage_event_and_token_usage/migration.sql @@ -0,0 +1,42 @@ +-- AlterTable +ALTER TABLE "AgentSession" ADD COLUMN "tokenUsage" JSONB; + +-- CreateTable +CREATE TABLE "ToolUsageEvent" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "companyUuid" TEXT NOT NULL, + "agentUuid" TEXT NOT NULL, + "sessionUuid" TEXT, + "toolName" TEXT NOT NULL, + "source" TEXT NOT NULL DEFAULT 'mcp', + "durationMs" INTEGER NOT NULL, + "inputSize" INTEGER NOT NULL, + "outputSize" INTEGER NOT NULL, + "isError" BOOLEAN NOT NULL DEFAULT false, + "errorText" TEXT, + "entityType" TEXT, + "entityUuid" TEXT, + "projectUuid" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ToolUsageEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ToolUsageEvent_uuid_key" ON "ToolUsageEvent"("uuid"); + +-- CreateIndex +CREATE INDEX "ToolUsageEvent_companyUuid_createdAt_idx" ON "ToolUsageEvent"("companyUuid", "createdAt"); + +-- CreateIndex +CREATE INDEX "ToolUsageEvent_agentUuid_createdAt_idx" ON "ToolUsageEvent"("agentUuid", "createdAt"); + +-- CreateIndex +CREATE INDEX "ToolUsageEvent_sessionUuid_idx" ON "ToolUsageEvent"("sessionUuid"); + +-- CreateIndex +CREATE INDEX "ToolUsageEvent_entityType_entityUuid_idx" ON "ToolUsageEvent"("entityType", "entityUuid"); + +-- CreateIndex +CREATE INDEX "ToolUsageEvent_projectUuid_createdAt_idx" ON "ToolUsageEvent"("projectUuid", "createdAt"); diff --git a/prisma/migrations/20260420021500_add_token_usage_record_remove_session_token_usage/migration.sql b/prisma/migrations/20260420021500_add_token_usage_record_remove_session_token_usage/migration.sql new file mode 100644 index 00000000..7aba68a7 --- /dev/null +++ b/prisma/migrations/20260420021500_add_token_usage_record_remove_session_token_usage/migration.sql @@ -0,0 +1,41 @@ +-- DropColumn +ALTER TABLE "AgentSession" DROP COLUMN IF EXISTS "tokenUsage"; + +-- CreateTable +CREATE TABLE "TokenUsageRecord" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "companyUuid" TEXT NOT NULL, + "agentUuid" TEXT NOT NULL, + "sessionUuid" TEXT, + "projectUuid" TEXT, + "entityType" TEXT, + "entityUuid" TEXT, + "inputTokens" INTEGER NOT NULL DEFAULT 0, + "outputTokens" INTEGER NOT NULL DEFAULT 0, + "cacheCreationInputTokens" INTEGER NOT NULL DEFAULT 0, + "cacheReadInputTokens" INTEGER NOT NULL DEFAULT 0, + "sourceSessionId" TEXT, + "turnTimestamp" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TokenUsageRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TokenUsageRecord_uuid_key" ON "TokenUsageRecord"("uuid"); + +-- CreateIndex +CREATE UNIQUE INDEX "TokenUsageRecord_sourceSessionId_turnTimestamp_key" ON "TokenUsageRecord"("sourceSessionId", "turnTimestamp"); + +-- CreateIndex +CREATE INDEX "TokenUsageRecord_companyUuid_idx" ON "TokenUsageRecord"("companyUuid"); + +-- CreateIndex +CREATE INDEX "TokenUsageRecord_agentUuid_idx" ON "TokenUsageRecord"("agentUuid"); + +-- CreateIndex +CREATE INDEX "TokenUsageRecord_projectUuid_idx" ON "TokenUsageRecord"("projectUuid"); + +-- CreateIndex +CREATE INDEX "TokenUsageRecord_entityType_entityUuid_idx" ON "TokenUsageRecord"("entityType", "entityUuid"); diff --git a/prisma/migrations/20260420121705_add_is_reviewer_to_token_usage_record/migration.sql b/prisma/migrations/20260420121705_add_is_reviewer_to_token_usage_record/migration.sql new file mode 100644 index 00000000..ab67c4d8 --- /dev/null +++ b/prisma/migrations/20260420121705_add_is_reviewer_to_token_usage_record/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TokenUsageRecord" ADD COLUMN "isReviewer" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4457b0a7..23c4c678 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -365,6 +365,62 @@ model AgentSession { @@index([status]) } +// Tool Usage Event (Layer 1 observability — MCP tool-logger persistence) +// Every MCP tool call is persisted asynchronously for aggregation queries. +model ToolUsageEvent { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + companyUuid String + agentUuid String + sessionUuid String? + toolName String + source String @default("mcp") // "mcp" (server-side) | "client" (CC hook upload) + durationMs Int + inputSize Int // JSON.stringify(params).length + outputSize Int // JSON.stringify(result).length + isError Boolean @default(false) + errorText String? + // Entity association (reuses presence.ts detectResource logic) + entityType String? // "task" | "idea" | "proposal" | "document" + entityUuid String? + projectUuid String? + createdAt DateTime @default(now()) + + @@index([companyUuid, createdAt]) + @@index([agentUuid, createdAt]) + @@index([sessionUuid]) + @@index([entityType, entityUuid]) + @@index([projectUuid, createdAt]) +} + +// Token Usage Record (Layer 3 observability — transcript-parsed token attribution) +// Each record attributes token usage to a specific entity (task/idea/proposal/project). +// Written incrementally per turn via Stop hook, with server-side dedup on (sourceSessionId, turnTimestamp). +model TokenUsageRecord { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + companyUuid String + agentUuid String + sessionUuid String? + projectUuid String? + entityType String? // "task" | "idea" | "proposal" | "project" + entityUuid String? + inputTokens Int @default(0) + outputTokens Int @default(0) + cacheCreationInputTokens Int @default(0) + cacheReadInputTokens Int @default(0) + isReviewer Boolean @default(false) + sourceSessionId String? // client session identifier (CC session_id, etc.) for dedup + turnTimestamp DateTime? // assistant turn timestamp from transcript for dedup + createdAt DateTime @default(now()) + + @@unique([sourceSessionId, turnTimestamp]) + @@index([companyUuid]) + @@index([agentUuid]) + @@index([projectUuid]) + @@index([entityType, entityUuid]) +} + // Session-Task checkin relation (many-to-many) model SessionTaskCheckin { id Int @id @default(autoincrement()) diff --git a/public/chorus-plugin/bin/chorus-api.sh b/public/chorus-plugin/bin/chorus-api.sh index dfe28dd6..6b1f0908 100755 --- a/public/chorus-plugin/bin/chorus-api.sh +++ b/public/chorus-plugin/bin/chorus-api.sh @@ -213,6 +213,105 @@ cmd_checkin() { api_call GET "/api/health" } +# Flush the local tool-log JSONL to Chorus. +# Atomic-mv the log aside, stream it as a single batch to +# POST /api/agent-report/tool-usage, delete on success, restore on failure. +# +# Usage: cmd_flush_tool_log [SESSION_UUID] +# SESSION_UUID is optional — server accepts null and still records events. +cmd_flush_tool_log() { + local session_uuid="${1:-}" + ensure_state + local log_file="${STATE_DIR}/tool-log.jsonl" + local lock_file="${log_file}.lock" + + # Nothing to do + [ -s "$log_file" ] || return 0 + + # Require jq for JSON array construction + command -v jq >/dev/null 2>&1 || return 0 + require_env + + # Atomic-move the log aside so new events keep accumulating in a fresh file. + local pending + pending="${log_file}.pending.$$" + ( + if command -v flock >/dev/null 2>&1; then + flock -w 2 202 || exit 1 + fi + [ -s "$log_file" ] || exit 2 + mv "$log_file" "$pending" + ) 202>"$lock_file" || return 0 + + [ -s "$pending" ] || { rm -f "$pending"; return 0; } + + # Build the JSON body: { sessionUuid?, events: [...] } + # Slurp JSONL into an array; if session_uuid is set, include it. + local body + if [ -n "$session_uuid" ]; then + body=$(jq -cs --arg s "$session_uuid" '{sessionUuid: $s, events: .}' "$pending" 2>/dev/null) || body="" + else + body=$(jq -cs '{events: .}' "$pending" 2>/dev/null) || body="" + fi + + if [ -z "$body" ]; then + # Malformed JSONL — drop the batch rather than looping forever. + rm -f "$pending" + return 0 + fi + + # Send the batch. Capture HTTP status so we can keep the pending file on failure. + local resp_file + resp_file=$(mktemp "${STATE_DIR}/.tool_upload_resp.XXXXXX") + local http_code + http_code=$(curl -s -S -X POST \ + -H "Authorization: Bearer ${CHORUS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$body" \ + -w "%{http_code}" \ + -o "$resp_file" \ + "${CHORUS_URL}/api/agent-report/tool-usage" 2>/dev/null) || http_code="000" + rm -f "$resp_file" + + # 2xx → success: drop pending. Otherwise: put pending back at the head of + # the log file so we retry on the next idle tick. + case "$http_code" in + 2??) + rm -f "$pending" + ;; + *) + # Restore: append any newly-arrived events after the pending batch. + ( + if command -v flock >/dev/null 2>&1; then + flock -w 2 203 || exit 1 + fi + if [ -f "$log_file" ]; then + cat "$log_file" >> "$pending" + mv "$pending" "$log_file" + else + mv "$pending" "$log_file" + fi + ) 203>"$lock_file" || rm -f "$pending" + return 1 + ;; + esac +} + +# Direct REST call with Bearer auth +# Usage: cmd_api_call METHOD PATH [DATA] +# DATA may also be piped on stdin when given as "-" +cmd_api_call() { + local method="${1:-}" + local path="${2:-}" + local data="${3:-}" + [ -n "$method" ] && [ -n "$path" ] || die "Usage: chorus-api.sh api-call METHOD PATH [DATA|-]" + if [ "$data" = "-" ]; then + data=$(cat) + fi + require_env + api_call "$method" "$path" "$data" +} + # Call an MCP tool via JSON-RPC over HTTP Streamable Transport # Usage: cmd_mcp_tool [arguments_json] # Returns the text content from the tool result @@ -364,6 +463,8 @@ shift || true case "$cmd" in checkin) cmd_checkin "$@" ;; + api-call) cmd_api_call "$@" ;; + flush-tool-log) cmd_flush_tool_log "$@" ;; mcp-tool) cmd_mcp_tool "$@" ;; state-get) state_get "${1:-}" ;; state-set) state_set "${1:-}" "${2:-}" ;; @@ -376,6 +477,8 @@ case "$cmd" in echo "" echo "Commands:" echo " checkin Check connectivity with Chorus" + echo " api-call METHOD PATH [DATA|-] Direct REST call (Bearer auth); use '-' to read DATA from stdin" + echo " flush-tool-log [sessionUuid] Upload .chorus/tool-log.jsonl to /api/agent-report/tool-usage" echo " mcp-tool [args_json] Call an MCP tool via JSON-RPC" echo " state-get Read from state.json" echo " state-set Write to state.json" diff --git a/public/chorus-plugin/bin/on-post-tool-log.sh b/public/chorus-plugin/bin/on-post-tool-log.sh new file mode 100755 index 00000000..281f1b5f --- /dev/null +++ b/public/chorus-plugin/bin/on-post-tool-log.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# on-post-tool-log.sh — PostToolUse hook, captures every tool call to local JSONL. +# Runs async:true so it never blocks Claude. Pure local file append, no network. +# +# Event JSON from stdin (CC provides): +# tool_name, tool_input, tool_response, tool_use_id, agent_id +# +# Behavior: +# - For MCP tools matching mcp__chorus__*, try to extract an entity UUID from +# tool_input (taskUuid/proposalUuid/ideaUuid/documentUuid/projectUuid/sessionUuid) +# and update the Active Context in .chorus/state.json. +# - Append a compact JSONL line to .chorus/tool-log.jsonl with: +# ts, tool, id (tool_use_id), agent (agent_id), input_len, output_len, +# is_error, entity_type, entity_uuid +# +# NOTE: Must be Bash 3.2 compatible. No ${VAR,,}, no declare -A, no readarray. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +API="${SCRIPT_DIR}/chorus-api.sh" +STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.chorus" +LOG_FILE="${STATE_DIR}/tool-log.jsonl" +LOCK_FILE="${LOG_FILE}.lock" + +# Read event JSON from stdin +EVENT="" +if [ ! -t 0 ]; then + EVENT=$(cat) +fi + +# Nothing to do without an event payload +if [ -z "$EVENT" ]; then + exit 0 +fi + +# jq is required for reliable parsing. If missing, skip silently — we don't +# want to break Claude Code when jq is absent; T0 set up is best-effort. +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +mkdir -p "$STATE_DIR" + +# Extract base fields (all optional, default to empty) +TOOL_NAME=$(printf '%s' "$EVENT" | jq -r '.tool_name // empty' 2>/dev/null || echo "") +TOOL_USE_ID=$(printf '%s' "$EVENT" | jq -r '.tool_use_id // empty' 2>/dev/null || echo "") +AGENT_ID=$(printf '%s' "$EVENT" | jq -r '.agent_id // empty' 2>/dev/null || echo "") + +# Without a tool name there's nothing useful to record +if [ -z "$TOOL_NAME" ]; then + exit 0 +fi + +# Compute sizes of input/output JSON payloads (byte length of the JSON text). +INPUT_LEN=$(printf '%s' "$EVENT" | jq -r '(.tool_input // null) | if . == null then 0 else (tojson | length) end' 2>/dev/null || echo "0") +OUTPUT_LEN=$(printf '%s' "$EVENT" | jq -r '(.tool_response // null) | if . == null then 0 else (tojson | length) end' 2>/dev/null || echo "0") + +# Detect error. CC sometimes reports errors as tool_response.is_error +# or as a top-level is_error, or via an "error" field inside tool_response. +IS_ERROR=$(printf '%s' "$EVENT" | jq -r ' + if (.tool_response | type) == "object" then + ((.tool_response.is_error // .tool_response.isError // false) | tostring) + elif (.is_error // false) then "true" + else "false" end +' 2>/dev/null || echo "false") + +# ===== Active Context update (Chorus MCP tools only) ===== +ENTITY_TYPE="" +ENTITY_UUID="" + +case "$TOOL_NAME" in + mcp__chorus*) + # Try known entity-UUID param names in priority order. + # First match wins. All are uuid-shaped strings in tool_input. + for KEY in taskUuid proposalUuid ideaUuid documentUuid projectUuid sessionUuid projectGroupUuid; do + VAL=$(printf '%s' "$EVENT" | jq -r --arg k "$KEY" '.tool_input[$k] // empty' 2>/dev/null || echo "") + if [ -n "$VAL" ]; then + ENTITY_UUID="$VAL" + # Map param name -> entity_type. Keep short canonical names. + case "$KEY" in + taskUuid) ENTITY_TYPE="task" ;; + proposalUuid) ENTITY_TYPE="proposal" ;; + ideaUuid) ENTITY_TYPE="idea" ;; + documentUuid) ENTITY_TYPE="document" ;; + projectUuid) ENTITY_TYPE="project" ;; + sessionUuid) ENTITY_TYPE="session" ;; + projectGroupUuid) ENTITY_TYPE="project_group" ;; + esac + break + fi + done + + # Update Active Context in state.json so other hooks / UI can surface + # "what is the agent currently looking at?". Best-effort. + if [ -n "$ENTITY_TYPE" ] && [ -n "$ENTITY_UUID" ]; then + "$API" state-set "active_${ENTITY_TYPE}_uuid" "$ENTITY_UUID" >/dev/null 2>&1 || true + "$API" state-set "active_entity_type" "$ENTITY_TYPE" >/dev/null 2>&1 || true + "$API" state-set "active_entity_uuid" "$ENTITY_UUID" >/dev/null 2>&1 || true + fi + ;; +esac + +# ===== Build compact JSONL entry ===== +TS=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "") + +LINE=$(jq -cn \ + --arg ts "$TS" \ + --arg tool "$TOOL_NAME" \ + --arg id "$TOOL_USE_ID" \ + --arg agent "$AGENT_ID" \ + --argjson input_len "${INPUT_LEN:-0}" \ + --argjson output_len "${OUTPUT_LEN:-0}" \ + --argjson is_error "${IS_ERROR:-false}" \ + --arg entity_type "$ENTITY_TYPE" \ + --arg entity_uuid "$ENTITY_UUID" \ + '{ + ts: $ts, + tool: $tool, + id: (if $id == "" then null else $id end), + agent: (if $agent == "" then null else $agent end), + input_len: $input_len, + output_len: $output_len, + is_error: $is_error, + entity_type: (if $entity_type == "" then null else $entity_type end), + entity_uuid: (if $entity_uuid == "" then null else $entity_uuid end) + }' 2>/dev/null) || exit 0 + +# Serialize concurrent appends across multiple teammate processes. +# flock on macOS (Bash 3.2) is available via util-linux shim in CI; on mac +# itself we fall back to a plain append (acceptable race for a log file). +if command -v flock >/dev/null 2>&1; then + ( + flock -w 2 201 || exit 0 + printf '%s\n' "$LINE" >> "$LOG_FILE" + ) 201>"$LOCK_FILE" || true +else + printf '%s\n' "$LINE" >> "$LOG_FILE" || true +fi + +# Async hooks: suppress any output so we don't perturb Claude's context. +exit 0 diff --git a/public/chorus-plugin/bin/on-session-end.sh b/public/chorus-plugin/bin/on-session-end.sh index 9098ada3..ab7b885f 100755 --- a/public/chorus-plugin/bin/on-session-end.sh +++ b/public/chorus-plugin/bin/on-session-end.sh @@ -1,18 +1,32 @@ #!/usr/bin/env bash # on-session-end.sh — SessionEnd hook # Fires when Claude Code session ends. -# Cleans up the .chorus/ directory if all sessions are closed and state is empty. +# 1. Parse transcript for token usage → POST turns + timeline to server +# 2. Clean up .chorus/ directory if all sessions are closed and state is empty set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +API="${SCRIPT_DIR}/chorus-api.sh" STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.chorus" -# Nothing to clean up +# Read event JSON from stdin +EVENT="" +if [ ! -t 0 ]; then + EVENT=$(cat) +fi + +# === Layer 3: Token upload removed === +# Main agent tokens are uploaded per-turn by on-stop.sh (Stop hook). +# on-session-end.sh has a 1.5s timeout and fires only on exit/clear/resume, +# making it unreliable for token capture. + +# === Cleanup .chorus/ directory === if [ ! -d "$STATE_DIR" ]; then exit 0 fi -# Safety check: don't delete if there are still active session files +# Don't delete if there are still active session files SESSIONS_DIR="${STATE_DIR}/sessions" if [ -d "$SESSIONS_DIR" ]; then REMAINING=0 @@ -25,9 +39,9 @@ if [ -d "$SESSIONS_DIR" ]; then fi fi -# Safety check: don't delete if state.json has meaningful content +# Don't delete if state.json has meaningful content if [ -f "${STATE_DIR}/state.json" ]; then - if command -v jq &>/dev/null; then + if command -v jq >/dev/null 2>&1; then KEY_COUNT=$(jq 'length' "${STATE_DIR}/state.json" 2>/dev/null) || KEY_COUNT=0 if [ "$KEY_COUNT" -gt 0 ]; then exit 0 diff --git a/public/chorus-plugin/bin/on-session-start.sh b/public/chorus-plugin/bin/on-session-start.sh index eb3b66b5..405c404e 100755 --- a/public/chorus-plugin/bin/on-session-start.sh +++ b/public/chorus-plugin/bin/on-session-start.sh @@ -97,15 +97,6 @@ When you or your sub-agents receive @mentions or other notifications: Projects are organized into Project Groups. Before creating a new project, call \`chorus_get_project_groups()\` to see existing groups and pass the \`groupUuid\` to \`chorus_admin_create_project()\` to assign the project to the correct group. Creating a project without specifying a group puts it in Ungrouped." -# Check for existing state (resumed session) -MAIN_SESSION=$("$API" state-get "main_session_uuid" 2>/dev/null) || true -if [ -n "$MAIN_SESSION" ]; then - CONTEXT="${CONTEXT} - -Resuming with existing Chorus session: ${MAIN_SESSION}" - "$API" mcp-tool "chorus_session_heartbeat" "$(printf '{"sessionUuid":"%s"}' "$MAIN_SESSION")" >/dev/null 2>&1 || true -fi - # Plan A: Session discovery for sub-agents SESSIONS_DIR="${CLAUDE_PROJECT_DIR:-.}/.chorus/sessions" if [ -d "$SESSIONS_DIR" ]; then @@ -141,8 +132,5 @@ fi # Build user-visible message USER_MSG="Chorus connected at ${CHORUS_URL}" -if [ -n "$MAIN_SESSION" ]; then - USER_MSG="${USER_MSG} (resumed session)" -fi "$API" hook-output "$USER_MSG" "$CONTEXT" "SessionStart" diff --git a/public/chorus-plugin/bin/on-stop.sh b/public/chorus-plugin/bin/on-stop.sh new file mode 100755 index 00000000..6d622710 --- /dev/null +++ b/public/chorus-plugin/bin/on-stop.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# on-stop.sh — Stop hook (fires every assistant turn, main agent only) +# Extracts per-API-call token usage with: +# - Streaming dedup (collapse consecutive same cache_read runs) +# - Delta cache_read (cumulative → incremental) +# - User-boundary round filtering (discard turns in rounds without chorus activity) +# Server replaces previous records for the same sourceSessionId. +# Bash 3.2 compatible. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.chorus" + +if [ -z "${CHORUS_URL:-}" ] || [ -z "${CHORUS_API_KEY:-}" ]; then + exit 0 +fi + +EVENT="" +if [ ! -t 0 ]; then + EVENT=$(cat) +fi +if [ -z "$EVENT" ]; then + exit 0 +fi +if ! command -v jq >/dev/null 2>&1; then + exit 0 +fi + +STOP_ACTIVE=$(echo "$EVENT" | jq -r '.stop_hook_active // false' 2>/dev/null) || true +if [ "$STOP_ACTIVE" = "true" ]; then + exit 0 +fi + +TRANSCRIPT_PATH=$(echo "$EVENT" | jq -r '.transcript_path // empty' 2>/dev/null) || true +SESSION_ID=$(echo "$EVENT" | jq -r '.session_id // empty' 2>/dev/null) || true + +if [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ] || [ ! -r "$TRANSCRIPT_PATH" ]; then + exit 0 +fi +if [ -z "$SESSION_ID" ]; then + exit 0 +fi + +mkdir -p "$STATE_DIR" + +EXTRACT_FILE=$(mktemp "${STATE_DIR}/.extract.XXXXXX") +TURNS_FILE=$(mktemp "${STATE_DIR}/.turns.XXXXXX") +TIMELINE_FILE=$(mktemp "${STATE_DIR}/.timeline.XXXXXX") +PAYLOAD_FILE=$(mktemp "${STATE_DIR}/.payload.XXXXXX") +cleanup() { rm -f "$EXTRACT_FILE" "$TURNS_FILE" "$TIMELINE_FILE" "$PAYLOAD_FILE"; } +trap cleanup EXIT + +# Step 1: Single-pass extraction — boundaries, raw turns, timeline. +cat "$TRANSCRIPT_PATH" | jq -cs '{ + boundaries: [.[] | select((.type == "user" or .type == "human") and .timestamp != null) | .timestamp], + raw_turns: [.[] | select(.type == "assistant" and .message.usage != null) | + {ts: (.timestamp // null), + input_tokens: (.message.usage.input_tokens // 0), + output_tokens: (.message.usage.output_tokens // 0), + cache_creation_input_tokens: (.message.usage.cache_creation_input_tokens // 0), + cr: (.message.usage.cache_read_input_tokens // 0)}], + timeline: [.[] | select(.type == "assistant") | + .timestamp as $ts | + (.message.content[]? // empty) | + select(.type == "tool_use" and (.name | test("chorus"))) | + .input as $in | + (if $in.taskUuid then {ts: $ts, entity_type: "task", entity_uuid: $in.taskUuid} + elif $in.proposalUuid then {ts: $ts, entity_type: "proposal", entity_uuid: $in.proposalUuid} + elif $in.ideaUuid then {ts: $ts, entity_type: "idea", entity_uuid: $in.ideaUuid} + elif $in.documentUuid then {ts: $ts, entity_type: "document", entity_uuid: $in.documentUuid} + else empty end)] +}' > "$EXTRACT_FILE" 2>/dev/null || echo '{"boundaries":[],"raw_turns":[],"timeline":[]}' > "$EXTRACT_FILE" + +# Step 2: Dedup streaming chunks + delta cache_read + round filtering. +# Only keep turns in user→assistant rounds that contain chorus tool calls. +jq ' + (.boundaries | sort) as $boundaries | + .timeline as $timeline | + (.raw_turns | reduce .[] as $item ([]; + if length == 0 then [$item] + elif (last.cr == $item.cr) then .[:-1] + [$item] + else . + [$item] + end + ) | . as $d | + [range(length) | . as $i | + $d[$i] + {cache_read_input_tokens: ($d[$i].cr - (if $i > 0 then $d[$i-1].cr else 0 end))} | + del(.cr)] | + [.[] | + . as $turn | + ([$boundaries[] | select(. <= $turn.ts)] | last // "") as $round_start | + ([$boundaries[] | select(. > $turn.ts)] | first // "Z") as $round_end | + if ([$timeline[] | select(.ts >= $round_start and .ts < $round_end)] | length > 0) then $turn + else empty end]) +' "$EXTRACT_FILE" > "$TURNS_FILE" 2>/dev/null || echo "[]" > "$TURNS_FILE" + +HAS_TURNS=$(jq -r 'length > 0' "$TURNS_FILE" 2>/dev/null) || HAS_TURNS="false" +if [ "$HAS_TURNS" != "true" ]; then + exit 0 +fi + +# Step 3: Timeline (extracted in step 1). +jq '.timeline' "$EXTRACT_FILE" > "$TIMELINE_FILE" 2>/dev/null || echo "[]" > "$TIMELINE_FILE" + +# Step 4: Build payload. +jq -cn --arg sid "$SESSION_ID" \ + '{sourceSessionId: $sid}' 2>/dev/null | \ + jq --slurpfile turns "$TURNS_FILE" --slurpfile timeline "$TIMELINE_FILE" \ + '. + {turns: $turns[0], timeline: $timeline[0]}' > "$PAYLOAD_FILE" 2>/dev/null + +if [ -s "$PAYLOAD_FILE" ]; then + curl -sS -X POST \ + -H "Authorization: Bearer ${CHORUS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d @"$PAYLOAD_FILE" \ + "${CHORUS_URL}/api/agent-report/token-usage" \ + >/dev/null 2>&1 || true +fi diff --git a/public/chorus-plugin/bin/on-subagent-stop.sh b/public/chorus-plugin/bin/on-subagent-stop.sh index 4a2f94b5..d18ce038 100755 --- a/public/chorus-plugin/bin/on-subagent-stop.sh +++ b/public/chorus-plugin/bin/on-subagent-stop.sh @@ -68,6 +68,10 @@ if [ -n "$SESSION_DETAIL" ]; then done fi +# Final flush of Layer-2 tool events before closing the session. Best-effort; +# if the upload fails the pending file is retained for retry by the next idle. +"$API" flush-tool-log "$SESSION_UUID" >/dev/null 2>&1 || true + # Close the Chorus session via MCP CLOSE_OK=true "$API" mcp-tool "chorus_close_session" "$(printf '{"sessionUuid":"%s"}' "$SESSION_UUID")" >/dev/null 2>&1 || CLOSE_OK=false @@ -92,6 +96,73 @@ if [ -n "$AGENT_ID" ] && [ -f "${CLAIMED_DIR}/${AGENT_ID}" ]; then rm -f "${CLAIMED_DIR}/${AGENT_ID}" fi +# === Layer 3: Sub-agent token usage → POST to server === +# Per-API-call usage: dedup streaming chunks + delta cache_read. +# SubagentStop fires once per sub-agent. + +TRANSCRIPT_PATH=$(echo "$EVENT" | jq -r '.agent_transcript_path // .agentTranscriptPath // .transcript_path // empty' 2>/dev/null) || true + +if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ] && [ -r "$TRANSCRIPT_PATH" ] && command -v jq >/dev/null 2>&1; then + L3_STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.chorus" + mkdir -p "$L3_STATE_DIR" + + TURNS_FILE=$(mktemp "${L3_STATE_DIR}/.turns.XXXXXX") + TIMELINE_FILE=$(mktemp "${L3_STATE_DIR}/.timeline.XXXXXX") + PAYLOAD_FILE=$(mktemp "${L3_STATE_DIR}/.payload.XXXXXX") + L3_CLEANUP() { rm -f "$TURNS_FILE" "$TIMELINE_FILE" "$PAYLOAD_FILE"; } + trap L3_CLEANUP EXIT + + # Last assistant turn = session snapshot (matches CC's total_tokens). + # Sub-agents have one primary entity, so no per-turn attribution needed. + cat "$TRANSCRIPT_PATH" | jq -cs ' + [.[] | select(.type == "assistant" and .message.usage != null)] | last // empty | + [{ts: (.timestamp // null), + input_tokens: (.message.usage.input_tokens // 0), + output_tokens: (.message.usage.output_tokens // 0), + cache_creation_input_tokens: (.message.usage.cache_creation_input_tokens // 0), + cache_read_input_tokens: (.message.usage.cache_read_input_tokens // 0)}] + ' > "$TURNS_FILE" 2>/dev/null || echo "[]" > "$TURNS_FILE" + + HAS_TURNS=$(jq -r 'length > 0' "$TURNS_FILE" 2>/dev/null) || HAS_TURNS="false" + if [ "$HAS_TURNS" = "true" ]; then + cat "$TRANSCRIPT_PATH" | jq -cs ' + [.[] | select(.type == "assistant") | + .timestamp as $ts | + (.message.content[]? // empty) | + select(.type == "tool_use" and (.name | test("chorus"))) | + .input as $in | + (if $in.taskUuid then {ts: $ts, entity_type: "task", entity_uuid: $in.taskUuid} + elif $in.proposalUuid then {ts: $ts, entity_type: "proposal", entity_uuid: $in.proposalUuid} + elif $in.ideaUuid then {ts: $ts, entity_type: "idea", entity_uuid: $in.ideaUuid} + elif $in.documentUuid then {ts: $ts, entity_type: "document", entity_uuid: $in.documentUuid} + else empty end)] + ' > "$TIMELINE_FILE" 2>/dev/null || echo "[]" > "$TIMELINE_FILE" + + # Detect reviewer from agent name (set by SubagentStart hook) + IS_REVIEWER="false" + AGENT_NAME_LC=$(echo "${AGENT_NAME:-}" | tr '[:upper:]' '[:lower:]') + case "$AGENT_NAME_LC" in + *reviewer*) IS_REVIEWER="true" ;; + esac + + jq -cn \ + --arg s "$SESSION_UUID" \ + --argjson reviewer "$IS_REVIEWER" \ + '{sessionUuid: $s, sourceSessionId: $s, isReviewer: $reviewer}' 2>/dev/null | \ + jq --slurpfile turns "$TURNS_FILE" --slurpfile timeline "$TIMELINE_FILE" \ + '. + {turns: $turns[0], timeline: $timeline[0]}' > "$PAYLOAD_FILE" 2>/dev/null + + if [ -s "$PAYLOAD_FILE" ]; then + curl -sS -X POST \ + -H "Authorization: Bearer ${CHORUS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d @"$PAYLOAD_FILE" \ + "${CHORUS_URL}/api/agent-report/token-usage" \ + >/dev/null 2>&1 || true + fi + fi +fi + # === Auto-dispatch: discover unblocked tasks === UNBLOCKED_INFO="" if [ "$CLOSE_OK" = true ] && [ -n "$SESSION_DETAIL" ]; then diff --git a/public/chorus-plugin/bin/on-teammate-idle.sh b/public/chorus-plugin/bin/on-teammate-idle.sh index 65b9a5fd..ea4ceb69 100755 --- a/public/chorus-plugin/bin/on-teammate-idle.sh +++ b/public/chorus-plugin/bin/on-teammate-idle.sh @@ -43,5 +43,9 @@ fi # Send heartbeat via MCP (suppress all output — heartbeats are too frequent to notify) "$API" mcp-tool "chorus_session_heartbeat" "$(printf '{"sessionUuid":"%s"}' "$SESSION_UUID")" >/dev/null 2>&1 || true +# Batch upload any buffered Layer-2 tool events. Best-effort; failures retry on +# the next idle tick (flush-tool-log restores the pending file on non-2xx). +"$API" flush-tool-log "$SESSION_UUID" >/dev/null 2>&1 || true + # Suppress output entirely — no systemMessage for heartbeats echo '{"suppressOutput": true}' diff --git a/public/chorus-plugin/bin/test-syntax.sh b/public/chorus-plugin/bin/test-syntax.sh index 8bd2ba69..e107255e 100644 --- a/public/chorus-plugin/bin/test-syntax.sh +++ b/public/chorus-plugin/bin/test-syntax.sh @@ -72,6 +72,8 @@ run_test "on-task-completed.sh" '{"task_id":"task-001"}' # --- PostToolUse hooks --- run_test "on-post-submit-proposal.sh" '{"tool_input":{"proposalUuid":"test-uuid"},"tool_response":{"uuid":"test-uuid","status":"pending","title":"Test proposal"}}' run_test "on-post-submit-for-verify.sh" '{"tool_input":{"taskUuid":"test-uuid"},"tool_response":{"uuid":"test-uuid","status":"to_verify","title":"Test task"}}' +run_test "on-post-tool-log.sh" '{"tool_name":"mcp__chorus__chorus_get_task","tool_use_id":"toolu_01","agent_id":"agent-xyz","tool_input":{"taskUuid":"11111111-2222-3333-4444-555555555555"},"tool_response":{"uuid":"11111111-2222-3333-4444-555555555555","title":"X"}}' +run_test "on-post-tool-log.sh" '{"tool_name":"Bash","tool_use_id":"toolu_02","agent_id":"agent-xyz","tool_input":{"command":"ls"},"tool_response":"hello world"}' # --- Session hooks --- run_test "on-session-start.sh" '{}' diff --git a/public/chorus-plugin/hooks/hooks.json b/public/chorus-plugin/hooks/hooks.json index 17f96a53..f94cf0db 100644 --- a/public/chorus-plugin/hooks/hooks.json +++ b/public/chorus-plugin/hooks/hooks.json @@ -39,6 +39,16 @@ "command": "${CLAUDE_PLUGIN_ROOT}/bin/on-post-submit-for-verify.sh" } ] + }, + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/on-post-tool-log.sh", + "async": true + } + ] } ], "PreToolUse": [ @@ -111,6 +121,18 @@ ] } ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/bin/on-stop.sh", + "async": true + } + ] + } + ], "SessionEnd": [ { "hooks": [ diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index d907d81b..204b6f16 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -PGLITE_DIR=".pglite" +PGLITE_DIR="${PGLITE_DIR:-.pglite}" +# Resolve ~ to $HOME (tilde isn't expanded inside quotes) +PGLITE_DIR="${PGLITE_DIR/#\~/$HOME}" PGLITE_PORT=5433 DATABASE_URL="postgresql://postgres:postgres@localhost:${PGLITE_PORT}/postgres?sslmode=disable" MAX_RETRIES=30 diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index e0c4885d..2ae694d9 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -14,6 +14,7 @@ import { Tags, CheckSquare, Activity, + BarChart3, FolderKanban, Settings, LogOut, @@ -241,6 +242,7 @@ export default function DashboardLayout({ { href: `/projects/${projectUuid}/proposals`, label: t("nav.proposals"), icon: Tags }, { href: `/projects/${projectUuid}/tasks`, label: t("nav.tasks"), icon: CheckSquare }, { href: `/projects/${projectUuid}/activity`, label: t("nav.activity"), icon: Activity }, + { href: `/projects/${projectUuid}/observability`, label: t("nav.observability"), icon: BarChart3 }, ]; // Global navigation items diff --git a/src/app/(dashboard)/projects/[uuid]/dashboard/panels/idea-detail-panel.tsx b/src/app/(dashboard)/projects/[uuid]/dashboard/panels/idea-detail-panel.tsx index a5df145d..0bee0452 100644 --- a/src/app/(dashboard)/projects/[uuid]/dashboard/panels/idea-detail-panel.tsx +++ b/src/app/(dashboard)/projects/[uuid]/dashboard/panels/idea-detail-panel.tsx @@ -28,6 +28,7 @@ import { ProposalView, type ProposalData } from "./proposal-view"; import { OverviewTimeline } from "./overview-timeline"; import { TaskListView } from "./task-list-view"; import { ActivityCommentsView } from "./activity-comments-view"; +import { TokensView } from "./tokens-view"; import { TaskDetailPanel } from "@/app/(dashboard)/projects/[uuid]/tasks/task-detail-panel"; import { DocumentPanel } from "./document-panel"; import { MoveIdeaDialog } from "./move-idea-dialog"; @@ -89,7 +90,7 @@ import { } from "../utils"; // ===== Tab Types ===== -type TabId = "overview" | "elaboration" | "proposal" | "tasks" | "activity"; +type TabId = "overview" | "elaboration" | "proposal" | "tasks" | "tokens" | "activity"; function getVisibleTabs( idea: IdeaWithDerivedStatus, @@ -100,6 +101,7 @@ function getVisibleTabs( tabs.push("elaboration"); if (proposals.length > 0) tabs.push("proposal"); if (tasks.length > 0) tabs.push("tasks"); + tabs.push("tokens"); tabs.push("activity"); return tabs; } @@ -682,6 +684,17 @@ export function IdeaDetailPanel({ )} + {/* Tokens Tab */} + {visibleTabs.includes("tokens") && visitedTabs.has("tokens") && ( +
+ +
+ )} + {/* Activity Tab */} {visitedTabs.has("activity") && (
diff --git a/src/app/(dashboard)/projects/[uuid]/dashboard/panels/tokens-view.tsx b/src/app/(dashboard)/projects/[uuid]/dashboard/panels/tokens-view.tsx new file mode 100644 index 00000000..7e34d1ec --- /dev/null +++ b/src/app/(dashboard)/projects/[uuid]/dashboard/panels/tokens-view.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Loader2, Coins, ChevronRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { useIdeaLifecycleTokens } from "@/hooks/use-observability"; +import { formatTokens } from "@/lib/format-tokens"; +import type { + LifecyclePhase, + TokenUsage, +} from "@/services/observability.service"; + +interface TokensViewProps { + ideaUuid: string; + projectUuid: string; + onSelectTask: (taskUuid: string) => void; +} + +const PHASE_ORDER: LifecyclePhase[] = [ + "elaboration", + "proposal", + "review", + "execution", + "verify", +]; + +function tokensSum(t: TokenUsage): number { + return ( + t.input_tokens + + t.output_tokens + + t.cache_creation_input_tokens + + t.cache_read_input_tokens + ); +} + +export function TokensView({ ideaUuid, projectUuid, onSelectTask }: TokensViewProps) { + const t = useTranslations(); + const { data, isLoading, error } = useIdeaLifecycleTokens(projectUuid, ideaUuid); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {t("observability.loadFailed")} +
+ ); + } + + if (!data) { + return ( +

{t("observability.noData")}

+ ); + } + + const total = tokensSum(data.totals.sessionTokens); + + if (total === 0 && data.totals.toolCallCount === 0) { + return ( +

{t("observability.noData")}

+ ); + } + + return ( +
+ {/* Summary */} + + +
+ + + {t("observability.totalTokens")} + +
+
+ + {formatTokens(total)} + + + {t("observability.outputTokens")} {formatTokens(data.totals.sessionTokens.output_tokens)} + +
+
+ + {t("observability.toolCalls")}:{" "} + + {data.totals.toolCallCount} + + +
+
+
+ + {/* Lifecycle rows */} +
+ +
+ {PHASE_ORDER.map((phase) => { + const p = data.phases.find((x) => x.phase === phase); + if (!p) return null; + const phaseTotal = tokensSum(p.sessionTokens); + if (phaseTotal === 0 && p.toolCallCount === 0) return null; + return ( + + +
+
+ {t(`observability.phase.${phase}`)} +
+
+ + {t("observability.toolCall", { count: p.toolCallCount })} + + {p.toolErrorCount > 0 && ( + + {t("observability.toolErrors", { + count: p.toolErrorCount, + })} + + )} +
+
+ + {formatTokens(phaseTotal)} + +
+
+ ); + })} +
+
+ + {/* Per-task rollup */} +
+ + {data.tasks.length === 0 ? ( +

+ {t("observability.noTasks")} +

+ ) : ( +
+ {data.tasks.map((task) => { + const taskTotal = tokensSum(task.sessionTokens); + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/projects/[uuid]/observability/agent-observability.tsx b/src/app/(dashboard)/projects/[uuid]/observability/agent-observability.tsx new file mode 100644 index 00000000..71d9b109 --- /dev/null +++ b/src/app/(dashboard)/projects/[uuid]/observability/agent-observability.tsx @@ -0,0 +1,380 @@ +"use client"; + +// src/app/(dashboard)/projects/[uuid]/observability/agent-observability.tsx +// Client component — date range toggle, agent selection, summary cards, detail view. + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { BarChart3 } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { authFetch } from "@/lib/auth-client"; +import { clientLogger } from "@/lib/logger-client"; +import type { + AgentObservabilityResult, + AgentObservabilityItem, +} from "@/services/observability.service"; +import { DailyTokenChart } from "./daily-token-chart"; +import { ToolUsageTable } from "./tool-usage-table"; + +// Heuristic role inference from agent name (the observability API doesn't +// return agent.roles). Falls back to a generic "Agent" label. +function inferRole(name: string): "pm" | "developer" | "admin" | null { + const lower = name.toLowerCase(); + if (lower.includes("admin")) return "admin"; + if (lower.includes("pm") || lower.includes("product")) return "pm"; + if (lower.includes("dev") || lower.includes("engineer") || lower.includes("worker")) { + return "developer"; + } + return null; +} + +type RangeDays = 7 | 30 | 90; + +interface AgentObservabilityProps { + projectUuid: string; + initialData: AgentObservabilityResult; +} + +function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +function totalTokens(item: AgentObservabilityItem): number { + const t = item.sessionTokens; + return t.input_tokens + t.output_tokens + t.cache_creation_input_tokens + t.cache_read_input_tokens; +} + +function isOnline(item: AgentObservabilityItem, from: Date): boolean { + if (item.dailySeries.length === 0) return false; + const last = item.dailySeries[item.dailySeries.length - 1].date; + const lastDate = new Date(`${last}T00:00:00.000Z`); + const todayKey = new Date().toISOString().slice(0, 10); + return last === todayKey || lastDate.getTime() >= from.getTime(); +} + +function roleLabel( + name: string, + t: (key: string) => string +): string { + const role = inferRole(name); + if (role === "pm") return t("observability.rolePm"); + if (role === "admin") return t("observability.roleAdmin"); + if (role === "developer") return t("observability.roleDeveloper"); + return t("observability.agent"); +} + +export function AgentObservability({ + projectUuid, + initialData, +}: AgentObservabilityProps) { + const t = useTranslations(); + const [days, setDays] = useState(7); + const [data, setData] = useState(initialData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedAgentUuid, setSelectedAgentUuid] = useState( + initialData.agents[0]?.agentUuid ?? null + ); + + // Reload data when date range changes. + useEffect(() => { + if (days === 7 && data.dateRange.days === 7 && data === initialData) { + return; + } + let cancelled = false; + setLoading(true); + setError(null); + authFetch(`/api/projects/${projectUuid}/observability?days=${days}`) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + if (!json.success) throw new Error(json.error ?? "Unknown"); + if (cancelled) return; + setData(json.data); + if ( + !selectedAgentUuid || + !json.data.agents.find( + (a: AgentObservabilityItem) => a.agentUuid === selectedAgentUuid + ) + ) { + setSelectedAgentUuid(json.data.agents[0]?.agentUuid ?? null); + } + }) + .catch((err) => { + if (cancelled) return; + clientLogger.error("Failed to load observability:", err); + setError(t("observability.loadError")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [days, projectUuid]); + + const summary = useMemo(() => { + let tokenSum = 0; + let cacheRead = 0; + let toolCalls = 0; + let errors = 0; + for (const a of data.agents) { + tokenSum += totalTokens(a); + cacheRead += a.sessionTokens.cache_read_input_tokens; + toolCalls += a.toolCallCount; + errors += a.toolErrorCount; + } + const errorRate = toolCalls > 0 ? (errors / toolCalls) * 100 : 0; + const perDay = days > 0 ? Math.round(toolCalls / days) : 0; + return { tokenSum, cacheRead, toolCalls, errorRate, perDay }; + }, [data, days]); + + const agents = data.agents; + + const selected = useMemo( + () => agents.find((a) => a.agentUuid === selectedAgentUuid) ?? null, + [agents, selectedAgentUuid] + ); + + const rangeButtons: Array<{ value: RangeDays; labelKey: string }> = [ + { value: 7, labelKey: "observability.range7d" }, + { value: 30, labelKey: "observability.range30d" }, + { value: 90, labelKey: "observability.range90d" }, + ]; + + return ( +
+ {/* Header */} +
+
+

+ {t("observability.title")} +

+

+ {t("observability.subtitle")} +

+
+ {/* Segmented range control */} +
+ {rangeButtons.map((btn, idx) => { + const active = days === btn.value; + return ( + + ); + })} +
+
+ + {error && ( + + {error} + + )} + + {/* Summary Cards */} +
+ + + + 5 ? "negative" : "muted"} + /> +
+ + {/* Body: agent list + detail */} + {agents.length === 0 ? ( + +
+ +
+

+ {t("observability.noData")} +

+

+ {t("observability.noDataDesc")} +

+
+ ) : ( +
+ {/* Agent list */} + +
+
+ {t("observability.agents")} +
+
+ {t("observability.agentsCount", { count: agents.length })} +
+
+
+ {agents.map((a) => { + const active = a.agentUuid === selectedAgentUuid; + const online = isOnline( + a, + new Date(Date.now() - 24 * 60 * 60 * 1000) + ); + return ( + + ); + })} +
+
+ + {/* Detail panel */} +
+ {selected ? ( + <> +
+
+ +

+ {selected.agentName} +

+
+ + {roleLabel(selected.agentName, t)} + +
+ + + + + + ) : ( + +

+ {t("observability.selectAgent")} +

+

+ {t("observability.selectAgentDesc")} +

+
+ )} +
+
+ )} +
+ ); +} + +function SummaryCard({ + label, + value, + hint, + hintTone, +}: { + label: string; + value: string; + hint?: string; + hintTone?: "positive" | "negative" | "muted"; +}) { + const hintColor = + hintTone === "negative" + ? "text-[#D32F2F]" + : hintTone === "positive" + ? "text-[#16a34a]" + : "text-[#9A9A9A]"; + return ( + +
{label}
+
+
+ {value} +
+ {hint && ( +
{hint}
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/projects/[uuid]/observability/daily-token-chart.tsx b/src/app/(dashboard)/projects/[uuid]/observability/daily-token-chart.tsx new file mode 100644 index 00000000..0ff91ad4 --- /dev/null +++ b/src/app/(dashboard)/projects/[uuid]/observability/daily-token-chart.tsx @@ -0,0 +1,159 @@ +"use client"; + +// src/app/(dashboard)/projects/[uuid]/observability/daily-token-chart.tsx +// Bar chart rendered with plain divs — stacked input (darker) over output (lighter). + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; +import type { AgentObservabilityItem } from "@/services/observability.service"; + +interface DailyTokenChartProps { + agent: AgentObservabilityItem; + days: number; + loading?: boolean; +} + +interface DayBucket { + date: string; + dayLabel: string; + input: number; + output: number; + total: number; + isToday: boolean; +} + +function formatDayLabel(date: string, todayKey: string): string { + if (date === todayKey) return ""; + const d = new Date(`${date}T00:00:00.000Z`); + return String(d.getUTCDate()); +} + +export function DailyTokenChart({ + agent, + days, + loading, +}: DailyTokenChartProps) { + const t = useTranslations(); + + const buckets = useMemo(() => { + const todayKey = new Date().toISOString().slice(0, 10); + + // Allocate one bucket per day in range. Pure call-count distribution is + // used here because the API doesn't give per-day input/output token + // breakdown; we split toolCallCount roughly by the agent's overall ratio + // between input and output so the stack is visually informative. + const totalCalls = agent.dailySeries.reduce( + (acc, d) => acc + d.toolCallCount, + 0 + ); + const inputTokens = agent.sessionTokens.input_tokens; + const outputTokens = agent.sessionTokens.output_tokens; + const tokensTotal = inputTokens + outputTokens; + + const map = new Map(agent.dailySeries.map((d) => [d.date, d.toolCallCount])); + + const out: DayBucket[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(); + d.setUTCHours(0, 0, 0, 0); + d.setUTCDate(d.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + const calls = map.get(key) ?? 0; + const ratio = totalCalls > 0 ? calls / totalCalls : 0; + const tokensForDay = tokensTotal * ratio; + const inputShare = + tokensTotal > 0 ? inputTokens / tokensTotal : 0.6; + const outputShare = 1 - inputShare; + out.push({ + date: key, + dayLabel: formatDayLabel(key, todayKey), + input: tokensForDay * inputShare, + output: tokensForDay * outputShare, + total: tokensForDay, + isToday: key === todayKey, + }); + } + return out; + }, [agent, days]); + + const maxTotal = useMemo( + () => Math.max(1, ...buckets.map((b) => b.total)), + [buckets] + ); + + // When a large range is selected, skip label rendering for non-today days to + // avoid a cramped axis. + const labelEveryN = days <= 7 ? 1 : days <= 30 ? 4 : 10; + + return ( + +
+
+ {t("observability.dailyTokenUsage")} +
+
+
+ + {t("observability.legendInput")} +
+
+ + {t("observability.legendOutput")} +
+
+
+ +
+ {buckets.map((b, idx) => { + const heightPct = + b.total > 0 ? Math.max(4, (b.total / maxTotal) * 100) : 2; + const inputPct = + b.total > 0 ? (b.input / b.total) * 100 : 0; + const outputPct = + b.total > 0 ? (b.output / b.total) * 100 : 0; + const showLabel = + b.isToday || idx === buckets.length - 1 || idx % labelEveryN === 0; + return ( +
+
+ {b.total > 0 ? ( + <> +
+
+ + ) : ( +
+ )} +
+
+ {showLabel + ? b.isToday + ? t("observability.today") + : b.dayLabel + : ""} +
+
+ ); + })} +
+ + ); +} diff --git a/src/app/(dashboard)/projects/[uuid]/observability/page.tsx b/src/app/(dashboard)/projects/[uuid]/observability/page.tsx new file mode 100644 index 00000000..7807090f --- /dev/null +++ b/src/app/(dashboard)/projects/[uuid]/observability/page.tsx @@ -0,0 +1,35 @@ +// src/app/(dashboard)/projects/[uuid]/observability/page.tsx +// Server Component — agent observability dashboard. + +import { redirect } from "next/navigation"; +import { getServerAuthContext } from "@/lib/auth-server"; +import { projectExists } from "@/services/project.service"; +import { getAgentObservability } from "@/services/observability.service"; +import { AgentObservability } from "./agent-observability"; + +interface PageProps { + params: Promise<{ uuid: string }>; +} + +export default async function ObservabilityPage({ params }: PageProps) { + const auth = await getServerAuthContext(); + if (!auth) { + redirect("/login"); + } + + const { uuid: projectUuid } = await params; + + const exists = await projectExists(auth.companyUuid, projectUuid); + if (!exists) { + redirect("/projects"); + } + + const initialData = await getAgentObservability(auth.companyUuid, projectUuid, 7); + + return ( + + ); +} diff --git a/src/app/(dashboard)/projects/[uuid]/observability/tool-usage-table.tsx b/src/app/(dashboard)/projects/[uuid]/observability/tool-usage-table.tsx new file mode 100644 index 00000000..f591e047 --- /dev/null +++ b/src/app/(dashboard)/projects/[uuid]/observability/tool-usage-table.tsx @@ -0,0 +1,128 @@ +"use client"; + +// src/app/(dashboard)/projects/[uuid]/observability/tool-usage-table.tsx +// Per-tool usage breakdown for the selected agent. + +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { ToolBreakdownItem } from "@/services/observability.service"; + +interface ToolUsageTableProps { + tools: ToolBreakdownItem[]; + loading?: boolean; +} + +function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return n.toLocaleString(); +} + +// MCP tools have the `chorus_` prefix; everything else is a Claude Code +// native tool (Bash, Read, Edit, etc.) so we use blue for the dot indicator. +function isMcpTool(name: string): boolean { + return name.startsWith("chorus_"); +} + +function displayName(name: string): string { + return isMcpTool(name) ? name.replace(/^chorus_/, "") : name; +} + +export function ToolUsageTable({ tools, loading }: ToolUsageTableProps) { + const t = useTranslations(); + + if (tools.length === 0) { + return ( + + {t("observability.noToolData")} + + ); + } + + return ( + + + + + + {t("observability.toolColumn")} + + + {t("observability.callsColumn")} + + + {t("observability.tokensColumn")} + + + {t("observability.avgMsColumn")} + + + {t("observability.errorsColumn")} + + + + + {tools.map((tool) => { + const mcp = isMcpTool(tool.toolName); + const avgMs = + tool.callCount > 0 + ? Math.round(tool.totalDurationMs / tool.callCount) + : 0; + const tokens = tool.totalInputSize + tool.totalOutputSize; + return ( + + +
+ + + {displayName(tool.toolName)} + +
+
+ + {tool.callCount.toLocaleString()} + + + {tokens > 0 ? formatTokens(tokens) : "—"} + + + {avgMs > 0 ? avgMs.toLocaleString() : "—"} + + 0 + ? "text-[#DC2626]" + : "text-[#9A9A9A]" + }`} + > + {tool.errorCount} + +
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx b/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx index f4febcce..c883e80c 100644 --- a/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx +++ b/src/app/(dashboard)/projects/[uuid]/proposals/[proposalUuid]/page.tsx @@ -28,6 +28,7 @@ import { ProposalEditor } from "./proposal-editor"; import { SourceIdeasCard } from "./source-ideas-card"; import { ProposalValidationChecklist } from "./proposal-validation-checklist"; import { DiscussionDrawer } from "./discussion-drawer"; +import { TokenUsageCard } from "./token-usage-card"; import { batchCommentCounts } from "@/services/comment.service"; import { normalizeNewlines } from "../../dashboard/panels/utils"; @@ -270,6 +271,12 @@ export default async function ProposalDetailPage({ params }: PageProps) { + {/* Token Usage Card — renders nothing when no data exists */} + + {/* Source Ideas Card */} {sourceIdeas.length > 0 && ( { + const events = await prisma.toolUsageEvent.findMany({ + where: { companyUuid, entityType: "proposal", entityUuid: proposalUuid }, + select: { + toolName: true, + createdAt: true, + inputSize: true, + outputSize: true, + }, + orderBy: { createdAt: "asc" }, + }); + const rounds: ReviewRound[] = []; + for (const ev of events) { + if (classifyPhase(ev.toolName) !== "review") continue; + const action: "pass" | "fail" = + ev.toolName === "chorus_admin_approve_proposal" ? "pass" : "fail"; + rounds.push({ + action, + createdAt: ev.createdAt, + inputSize: ev.inputSize, + outputSize: ev.outputSize, + }); + } + return rounds; +} + +// Classify draft tool calls into doc/task/validate buckets for a stacked bar. +function draftingSplit(breakdown: { toolName: string; callCount: number }[]): { + docs: number; + tasks: number; + validate: number; + other: number; +} { + let docs = 0; + let tasks = 0; + let validate = 0; + let other = 0; + for (const b of breakdown) { + if (b.toolName.includes("document_draft")) { + docs += b.callCount; + } else if (b.toolName.includes("task_draft")) { + tasks += b.callCount; + } else if (b.toolName.includes("validate")) { + validate += b.callCount; + } else { + other += b.callCount; + } + } + return { docs, tasks, validate, other }; +} + +export async function TokenUsageCard({ + companyUuid, + proposalUuid, +}: TokenUsageCardProps) { + const [entity, proposal, reviewRounds, t] = await Promise.all([ + getEntityTokens(companyUuid, "proposal", proposalUuid), + getProposalTokens(companyUuid, proposalUuid), + getReviewRounds(companyUuid, proposalUuid), + getTranslations(), + ]); + + const total = tokensSum(entity.sessionTokens); + + // Progressive rendering: if no data at all, skip the card entirely. + if (total === 0 && entity.toolCallCount === 0 && reviewRounds.length === 0) { + return null; + } + + const split = draftingSplit(proposal.drafting.toolBreakdown); + const draftTotal = + split.docs + split.tasks + split.validate + split.other; + + return ( + + + + + {t("observability.title")} + + + + {/* Total */} +
+
+ + {t("observability.totalTokens")} + + + {formatTokens(total)} + +
+
+ {t("observability.toolCalls")} + {entity.toolCallCount} +
+
+ + {/* Drafting breakdown */} + {draftTotal > 0 && ( + <> + +
+
+ + {t("observability.draftingBreakdown")} + + + {t("observability.toolCall", { count: draftTotal })} + +
+
+ {split.docs > 0 && ( +
+ )} + {split.tasks > 0 && ( +
+ )} + {split.validate > 0 && ( +
+ )} +
+
+ {split.docs > 0 && ( +
+ + + {t("observability.draftingDocs")} + + {split.docs} +
+ )} + {split.tasks > 0 && ( +
+ + + {t("observability.draftingTasks")} + + {split.tasks} +
+ )} + {split.validate > 0 && ( +
+ + + {t("observability.draftingValidate")} + + + {split.validate} + +
+ )} +
+
+ + )} + + {/* Review rounds */} + {reviewRounds.length > 0 && ( + <> + +
+
+ + {t("observability.reviewRounds")} + + + {reviewRounds.length} + +
+
+ {reviewRounds.map((round, idx) => ( +
+
+ + #{idx + 1} + + + {round.action === "pass" + ? t("observability.reviewPass") + : t("observability.reviewFail")} + +
+ + {formatTokens(round.inputSize + round.outputSize)} + +
+ ))} +
+
+ + )} + + + ); +} diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/task-detail-panel.tsx b/src/app/(dashboard)/projects/[uuid]/tasks/task-detail-panel.tsx index 66e4c691..f3f9d866 100644 --- a/src/app/(dashboard)/projects/[uuid]/tasks/task-detail-panel.tsx +++ b/src/app/(dashboard)/projects/[uuid]/tasks/task-detail-panel.tsx @@ -56,6 +56,7 @@ import { useRealtimeEntityEvent } from "@/contexts/realtime-context"; import { motion } from "framer-motion"; import { fadeIn } from "@/lib/animation"; import { PANEL_WIDTH_PX } from "@/app/(dashboard)/projects/[uuid]/dashboard/utils"; +import { TaskTokensView } from "./task-tokens-view"; interface DependencyTask { uuid: string; @@ -247,6 +248,7 @@ export function TaskDetailPanel({ }, []); const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState<"details" | "tokens">("details"); const [activities, setActivities] = useState([]); const [isLoadingActivities, setIsLoadingActivities] = useState(true); const [source, setSource] = useState(null); @@ -739,11 +741,35 @@ export function TaskDetailPanel({
+ {/* Tab Bar - only when viewing existing task (not edit/create mode) */} + {task && !isEditing && ( +
+
+ {(["details", "tokens"] as const).map((tab) => ( + + ))} +
+
+ )} + {/* Panel Body - Scrollable */}
{isEditing ? ( renderEditForm() + ) : task && activeTab === "tokens" ? ( + ) : task ? ( {/* Assignee Section */} diff --git a/src/app/(dashboard)/projects/[uuid]/tasks/task-tokens-view.tsx b/src/app/(dashboard)/projects/[uuid]/tasks/task-tokens-view.tsx new file mode 100644 index 00000000..6861b290 --- /dev/null +++ b/src/app/(dashboard)/projects/[uuid]/tasks/task-tokens-view.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Loader2, Coins } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { useEntityTokens } from "@/hooks/use-observability"; +import { formatTokens } from "@/lib/format-tokens"; +import type { TokenUsage } from "@/services/observability.service"; + +interface TaskTokensViewProps { + taskUuid: string; + projectUuid: string; +} + +function tokensSum(t: TokenUsage): number { + return t.input_tokens + t.output_tokens + t.cache_creation_input_tokens + t.cache_read_input_tokens; +} + +export function TaskTokensView({ taskUuid, projectUuid }: TaskTokensViewProps) { + const t = useTranslations(); + const { data, isLoading, error } = useEntityTokens(projectUuid, "task", taskUuid); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {t("observability.loadFailed")} +
+ ); + } + + if (!data) { + return ( +

{t("observability.noData")}

+ ); + } + + const tu = data.sessionTokens; + const total = tokensSum(tu); + + if (total === 0 && data.toolCallCount === 0) { + return ( +

{t("observability.noData")}

+ ); + } + + const splitRow = (label: string, value: number, colorClass: string) => ( +
+ {label} + + {formatTokens(value)} + +
+ ); + + return ( +
+ {/* Summary */} + + +
+ + + {t("observability.totalTokens")} + +
+
+ {formatTokens(total)} +
+
+ {splitRow(t("observability.input"), tu.input_tokens, "text-[#2C2C2C]")} + {splitRow(t("observability.output"), tu.output_tokens, "text-[#2C2C2C]")} + {splitRow( + t("observability.cacheRead"), + tu.cache_read_input_tokens, + "text-[#5A9E6F]" + )} + {splitRow( + t("observability.cacheWrite"), + tu.cache_creation_input_tokens, + "text-[#1976D2]" + )} +
+
+
+ + {/* Session info */} +
+ + + + + {t("observability.sessions")} + + + {data.sessionCount} + + + +
+ + {/* Tool timeline */} +
+
+ + + {t("observability.toolCall", { count: data.toolCallCount })} + +
+ {data.toolBreakdown.length === 0 ? ( +

+ {t("observability.noData")} +

+ ) : ( +
+ {data.toolBreakdown.map((tool) => ( + + +
+
+ {tool.toolName} +
+
+ + {t("observability.toolCall", { count: tool.callCount })} + + {tool.errorCount > 0 && ( + + {t("observability.toolErrors", { + count: tool.errorCount, + })} + + )} +
+
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/api/agent-report/token-usage/route.ts b/src/app/api/agent-report/token-usage/route.ts new file mode 100644 index 00000000..6909db24 --- /dev/null +++ b/src/app/api/agent-report/token-usage/route.ts @@ -0,0 +1,67 @@ +// src/app/api/agent-report/token-usage/route.ts +// Agent-only endpoint: receive transcript turns + tool timeline, attribute and store token usage. +// Supports incremental upload with server-side dedup on (sourceSessionId, turnTimestamp). +// Server resolves projectUuid from entity UUIDs — client doesn't need to provide it. +// Auth: Bearer API Key only (auth.type === "agent"). + +import { NextRequest } from "next/server"; +import { withErrorHandler, parseBody } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext, isAgent } from "@/lib/auth"; +import { + attributeTokenUsage, + insertAttributedTokenUsage, + resolveProjectUuids, + type TurnUsage, + type TimelineEntry, +} from "@/services/observability.service"; + +interface Body { + sessionUuid?: string; + sourceSessionId?: string; + isReviewer?: boolean; + turns?: TurnUsage[]; + timeline?: TimelineEntry[]; +} + +export const POST = withErrorHandler(async (request: NextRequest) => { + const auth = await getAuthContext(request); + if (!auth) return errors.unauthorized(); + if (!isAgent(auth)) { + return errors.forbidden("Agent authentication required"); + } + + const body = await parseBody(request); + const turns = Array.isArray(body.turns) ? body.turns : []; + if (turns.length === 0) { + return errors.badRequest("turns must be a non-empty array"); + } + + const timeline = Array.isArray(body.timeline) ? body.timeline : []; + const sessionUuid = + typeof body.sessionUuid === "string" ? body.sessionUuid : null; + const sourceSessionId = + typeof body.sourceSessionId === "string" ? body.sourceSessionId : null; + const isReviewer = body.isReviewer === true; + + const records = attributeTokenUsage( + turns, + timeline, + sessionUuid, + auth.actorUuid, + auth.companyUuid, + sourceSessionId, + isReviewer + ); + + const projectMap = await resolveProjectUuids(auth.companyUuid, records); + + const result = await insertAttributedTokenUsage( + records.map((r) => ({ + ...r, + projectUuid: + r.entityUuid ? (projectMap.get(r.entityUuid) ?? null) : null, + })) + ); + return success(result); +}); diff --git a/src/app/api/agent-report/tool-usage/route.ts b/src/app/api/agent-report/tool-usage/route.ts new file mode 100644 index 00000000..5884461c --- /dev/null +++ b/src/app/api/agent-report/tool-usage/route.ts @@ -0,0 +1,53 @@ +// src/app/api/agent-report/tool-usage/route.ts +// Agent-only endpoint: batch upload Layer-2 (CC client-side) ToolUsageEvent rows. +// Auth: Bearer API Key only (auth.type === "agent"). Rejects user/admin. + +import { NextRequest } from "next/server"; +import { withErrorHandler, parseBody } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext, isAgent } from "@/lib/auth"; +import { + batchInsertClientToolEvents, + type ClientToolEventInput, +} from "@/services/observability.service"; + +interface Body { + sessionUuid?: string; + events?: ClientToolEventInput[]; +} + +// Cap a single batch to avoid runaway payloads. +const MAX_EVENTS_PER_BATCH = 500; + +export const POST = withErrorHandler(async (request: NextRequest) => { + const auth = await getAuthContext(request); + if (!auth) return errors.unauthorized(); + if (!isAgent(auth)) { + return errors.forbidden("Agent authentication required"); + } + + const body = await parseBody(request); + const sessionUuid = typeof body.sessionUuid === "string" ? body.sessionUuid : null; + const events = Array.isArray(body.events) ? body.events : null; + if (!events) return errors.badRequest("events must be an array"); + if (events.length > MAX_EVENTS_PER_BATCH) { + return errors.badRequest( + `events exceed per-batch limit of ${MAX_EVENTS_PER_BATCH}` + ); + } + + // Minimal validation on each event: must have a tool name. + for (const e of events) { + if (!e || typeof e.tool !== "string" || e.tool.length === 0) { + return errors.badRequest("each event must have a 'tool' string"); + } + } + + const result = await batchInsertClientToolEvents( + auth.companyUuid, + auth.actorUuid, + sessionUuid, + events + ); + return success(result); +}); diff --git a/src/app/api/projects/[uuid]/observability/entity/route.ts b/src/app/api/projects/[uuid]/observability/entity/route.ts new file mode 100644 index 00000000..7db23b7b --- /dev/null +++ b/src/app/api/projects/[uuid]/observability/entity/route.ts @@ -0,0 +1,47 @@ +// src/app/api/projects/[uuid]/observability/entity/route.ts +// User-facing endpoint: per-entity token/tool aggregation. + +import { NextRequest } from "next/server"; +import { withErrorHandler, parseQuery } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext } from "@/lib/auth"; +import { getProject } from "@/services/project.service"; +import { + getEntityTokens, + getProposalTokens, + type EntityType, +} from "@/services/observability.service"; + +type RouteContext = { params: Promise<{ uuid: string }> }; + +const VALID_ENTITY_TYPES: EntityType[] = ["task", "idea", "proposal", "document"]; + +export const GET = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) return errors.unauthorized(); + + const { uuid: projectUuid } = await context.params; + const project = await getProject(auth.companyUuid, projectUuid); + if (!project) return errors.notFound("Project"); + + const q = parseQuery(request); + const entityType = q.entityType as EntityType | undefined; + const entityUuid = q.entityUuid; + if (!entityType || !VALID_ENTITY_TYPES.includes(entityType)) { + return errors.badRequest( + `entityType must be one of: ${VALID_ENTITY_TYPES.join(", ")}` + ); + } + if (!entityUuid) return errors.badRequest("entityUuid is required"); + + const data = await getEntityTokens(auth.companyUuid, entityType, entityUuid); + + // Enrich proposals with drafting/review split. + if (entityType === "proposal") { + const proposal = await getProposalTokens(auth.companyUuid, entityUuid); + return success({ ...data, proposal }); + } + return success(data); + } +); diff --git a/src/app/api/projects/[uuid]/observability/idea/[ideaUuid]/route.ts b/src/app/api/projects/[uuid]/observability/idea/[ideaUuid]/route.ts new file mode 100644 index 00000000..50a794c8 --- /dev/null +++ b/src/app/api/projects/[uuid]/observability/idea/[ideaUuid]/route.ts @@ -0,0 +1,33 @@ +// src/app/api/projects/[uuid]/observability/idea/[ideaUuid]/route.ts +// User-facing endpoint: lifecycle phase breakdown for a single idea. + +import { NextRequest } from "next/server"; +import { withErrorHandler } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext } from "@/lib/auth"; +import { getProject } from "@/services/project.service"; +import { getIdeaLifecycleTokens } from "@/services/observability.service"; +import { prisma } from "@/lib/prisma"; + +type RouteContext = { params: Promise<{ uuid: string; ideaUuid: string }> }; + +export const GET = withErrorHandler<{ uuid: string; ideaUuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) return errors.unauthorized(); + + const { uuid: projectUuid, ideaUuid } = await context.params; + const project = await getProject(auth.companyUuid, projectUuid); + if (!project) return errors.notFound("Project"); + + // Ensure the idea belongs to this project+company. + const idea = await prisma.idea.findFirst({ + where: { uuid: ideaUuid, companyUuid: auth.companyUuid, projectUuid }, + select: { uuid: true }, + }); + if (!idea) return errors.notFound("Idea"); + + const data = await getIdeaLifecycleTokens(auth.companyUuid, ideaUuid); + return success(data); + } +); diff --git a/src/app/api/projects/[uuid]/observability/route.ts b/src/app/api/projects/[uuid]/observability/route.ts new file mode 100644 index 00000000..2012d169 --- /dev/null +++ b/src/app/api/projects/[uuid]/observability/route.ts @@ -0,0 +1,31 @@ +// src/app/api/projects/[uuid]/observability/route.ts +// User-facing agent observability dashboard data for a project. + +import { NextRequest } from "next/server"; +import { withErrorHandler, parseQuery } from "@/lib/api-handler"; +import { success, errors } from "@/lib/api-response"; +import { getAuthContext } from "@/lib/auth"; +import { getProject } from "@/services/project.service"; +import { getAgentObservability } from "@/services/observability.service"; + +type RouteContext = { params: Promise<{ uuid: string }> }; + +const ALLOWED_DAYS = new Set([7, 30, 90]); + +export const GET = withErrorHandler<{ uuid: string }>( + async (request: NextRequest, context: RouteContext) => { + const auth = await getAuthContext(request); + if (!auth) return errors.unauthorized(); + + const { uuid: projectUuid } = await context.params; + const project = await getProject(auth.companyUuid, projectUuid); + if (!project) return errors.notFound("Project"); + + const q = parseQuery(request); + const parsed = parseInt(q.days ?? "30", 10); + const days = ALLOWED_DAYS.has(parsed) ? parsed : 30; + + const data = await getAgentObservability(auth.companyUuid, projectUuid, days); + return success(data); + } +); diff --git a/src/hooks/use-observability.ts b/src/hooks/use-observability.ts new file mode 100644 index 00000000..ecb2f891 --- /dev/null +++ b/src/hooks/use-observability.ts @@ -0,0 +1,119 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { + EntityTokensResult, + IdeaLifecycleResult, + ProposalTokensResult, +} from "@/services/observability.service"; +import { clientLogger } from "@/lib/logger-client"; + +// The /entity endpoint returns EntityTokensResult, and additionally { proposal } +// when entityType === "proposal". +export type EntityTokensWithProposal = EntityTokensResult & { + proposal?: ProposalTokensResult; +}; + +interface FetchState { + data: T | null; + isLoading: boolean; + error: string | null; +} + +async function fetchJson(url: string): Promise { + const res = await fetch(url, { cache: "no-store" }); + const body = (await res.json()) as + | { success: true; data: T } + | { success: false; error: string }; + if (!res.ok || !body.success) { + const message = !body.success ? body.error : `Request failed: ${res.status}`; + throw new Error(message); + } + return body.data; +} + +export function useIdeaLifecycleTokens( + projectUuid: string | null | undefined, + ideaUuid: string | null | undefined +): FetchState & { refetch: () => void } { + const [state, setState] = useState>({ + data: null, + isLoading: Boolean(projectUuid && ideaUuid), + error: null, + }); + + const refetch = useCallback(() => { + if (!projectUuid || !ideaUuid) { + setState({ data: null, isLoading: false, error: null }); + return; + } + let cancelled = false; + setState((s) => ({ ...s, isLoading: true, error: null })); + fetchJson( + `/api/projects/${projectUuid}/observability/idea/${ideaUuid}` + ) + .then((data) => { + if (!cancelled) setState({ data, isLoading: false, error: null }); + }) + .catch((e: unknown) => { + if (cancelled) return; + const message = e instanceof Error ? e.message : String(e); + clientLogger.error("useIdeaLifecycleTokens failed:", e); + setState({ data: null, isLoading: false, error: message }); + }); + return () => { + cancelled = true; + }; + }, [projectUuid, ideaUuid]); + + useEffect(() => { + const cancel = refetch(); + return cancel; + }, [refetch]); + + return { ...state, refetch }; +} + +export function useEntityTokens( + projectUuid: string | null | undefined, + entityType: "task" | "idea" | "proposal" | "document" | null | undefined, + entityUuid: string | null | undefined +): FetchState & { refetch: () => void } { + const [state, setState] = useState>({ + data: null, + isLoading: Boolean(projectUuid && entityType && entityUuid), + error: null, + }); + + const refetch = useCallback(() => { + if (!projectUuid || !entityType || !entityUuid) { + setState({ data: null, isLoading: false, error: null }); + return; + } + let cancelled = false; + setState((s) => ({ ...s, isLoading: true, error: null })); + const url = `/api/projects/${projectUuid}/observability/entity?entityType=${encodeURIComponent( + entityType + )}&entityUuid=${encodeURIComponent(entityUuid)}`; + fetchJson(url) + .then((data) => { + if (!cancelled) setState({ data, isLoading: false, error: null }); + }) + .catch((e: unknown) => { + if (cancelled) return; + const message = e instanceof Error ? e.message : String(e); + clientLogger.error("useEntityTokens failed:", e); + setState({ data: null, isLoading: false, error: message }); + }); + return () => { + cancelled = true; + }; + }, [projectUuid, entityType, entityUuid]); + + useEffect(() => { + const cancel = refetch(); + return cancel; + }, [refetch]); + + return { ...state, refetch }; +} diff --git a/src/lib/__tests__/format-tokens.test.ts b/src/lib/__tests__/format-tokens.test.ts new file mode 100644 index 00000000..fde5b76b --- /dev/null +++ b/src/lib/__tests__/format-tokens.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { formatTokens } from "../format-tokens"; + +describe("formatTokens", () => { + describe("values under 1000", () => { + it("returns '0' for 0", () => { + expect(formatTokens(0)).toBe("0"); + }); + + it("returns the number as-is for small values", () => { + expect(formatTokens(1)).toBe("1"); + expect(formatTokens(42)).toBe("42"); + expect(formatTokens(742)).toBe("742"); + }); + + it("returns '999' at the boundary", () => { + expect(formatTokens(999)).toBe("999"); + }); + }); + + describe("k range (1000 to 999999)", () => { + it("formats 1000 as '1k'", () => { + expect(formatTokens(1000)).toBe("1k"); + }); + + it("formats 1234 as '1.2k'", () => { + expect(formatTokens(1234)).toBe("1.2k"); + }); + + it("formats 3800 as '3.8k'", () => { + expect(formatTokens(3800)).toBe("3.8k"); + }); + + it("strips trailing zero: 5000 -> '5k' not '5.0k'", () => { + expect(formatTokens(5000)).toBe("5k"); + }); + + it("formats 999999 at the upper boundary", () => { + expect(formatTokens(999999)).toBe("1000k"); + }); + + it("formats 10500 as '10.5k'", () => { + expect(formatTokens(10500)).toBe("10.5k"); + }); + + it("formats 100000 as '100k'", () => { + expect(formatTokens(100000)).toBe("100k"); + }); + }); + + describe("M range (1000000+)", () => { + it("formats 1000000 as '1M'", () => { + expect(formatTokens(1000000)).toBe("1M"); + }); + + it("formats 1234567 as '1.2M'", () => { + expect(formatTokens(1234567)).toBe("1.2M"); + }); + + it("formats 1500000 as '1.5M'", () => { + expect(formatTokens(1500000)).toBe("1.5M"); + }); + + it("strips trailing zero: 2000000 -> '2M' not '2.0M'", () => { + expect(formatTokens(2000000)).toBe("2M"); + }); + + it("formats large values like 150000000 as '150M'", () => { + expect(formatTokens(150000000)).toBe("150M"); + }); + }); + + describe("negative values", () => { + it("preserves negative prefix for small values", () => { + expect(formatTokens(-42)).toBe("-42"); + }); + + it("formats -1234 as '-1.2k'", () => { + expect(formatTokens(-1234)).toBe("-1.2k"); + }); + + it("formats -1234567 as '-1.2M'", () => { + expect(formatTokens(-1234567)).toBe("-1.2M"); + }); + + it("formats -999 as '-999'", () => { + expect(formatTokens(-999)).toBe("-999"); + }); + }); + + describe("non-finite and null/undefined values", () => { + it("returns '0' for NaN", () => { + expect(formatTokens(NaN)).toBe("0"); + }); + + it("returns '0' for Infinity", () => { + expect(formatTokens(Infinity)).toBe("0"); + }); + + it("returns '0' for -Infinity", () => { + expect(formatTokens(-Infinity)).toBe("0"); + }); + + it("returns '0' for null", () => { + expect(formatTokens(null)).toBe("0"); + }); + + it("returns '0' for undefined", () => { + expect(formatTokens(undefined)).toBe("0"); + }); + }); + + describe("rounding", () => { + it("rounds fractional input to nearest integer before formatting", () => { + expect(formatTokens(999.4)).toBe("999"); + expect(formatTokens(999.5)).toBe("1k"); + }); + }); +}); diff --git a/src/lib/format-tokens.ts b/src/lib/format-tokens.ts new file mode 100644 index 00000000..fe870165 --- /dev/null +++ b/src/lib/format-tokens.ts @@ -0,0 +1,16 @@ +// Token count formatter: 420 -> "420", 3_800 -> "3.8k", 1_250_000 -> "1.2M" +export function formatTokens(n: number | null | undefined): string { + if (n == null || !Number.isFinite(n)) return "0"; + + const negative = n < 0; + const abs = Math.abs(Math.round(n)); + const prefix = negative ? "-" : ""; + + if (abs < 1_000) return `${prefix}${abs}`; + if (abs < 1_000_000) { + const k = abs / 1_000; + return `${prefix}${k.toFixed(1).replace(/\.0$/, "")}k`; + } + const m = abs / 1_000_000; + return `${prefix}${m.toFixed(1).replace(/\.0$/, "")}M`; +} diff --git a/src/mcp/__tests__/tool-logger.test.ts b/src/mcp/__tests__/tool-logger.test.ts index fc273369..c58dcfe4 100644 --- a/src/mcp/__tests__/tool-logger.test.ts +++ b/src/mcp/__tests__/tool-logger.test.ts @@ -2,11 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { truncateParams, extractErrorText } from "../tools/tool-logger"; import type { AgentAuthContext } from "@/types/auth"; -// Mock logger — vi.hoisted ensures fns exist before vi.mock runs -const { mockDebug, mockWarn, mockError } = vi.hoisted(() => ({ +// Mocks — vi.hoisted ensures fns exist before vi.mock runs +const { + mockDebug, + mockWarn, + mockError, + mockToolUsageEventCreate, + mockDetectResource, + mockResolveProjectUuid, +} = vi.hoisted(() => ({ mockDebug: vi.fn(), mockWarn: vi.fn(), mockError: vi.fn(), + mockToolUsageEventCreate: vi.fn().mockResolvedValue({}), + mockDetectResource: vi.fn(), + mockResolveProjectUuid: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/logger", () => ({ @@ -19,7 +29,18 @@ vi.mock("@/lib/logger", () => ({ }, })); -// Import after mock so enableToolCallLogging gets the mocked logger +vi.mock("@/lib/prisma", () => ({ + prisma: { + toolUsageEvent: { create: mockToolUsageEventCreate }, + }, +})); + +vi.mock("../tools/presence", () => ({ + detectResource: mockDetectResource, + resolveProjectUuid: mockResolveProjectUuid, +})); + +// Import after mocks so enableToolCallLogging gets mocked deps import { enableToolCallLogging } from "../tools/tool-logger"; // Minimal McpServer stub @@ -46,9 +67,20 @@ const mockAuth: AgentAuthContext = { roles: ["developer_agent"], }; +// Helper to flush microtask queue so fire-and-forget persistence completes before assertions. +async function flushMicrotasks() { + // Several awaits to drain chained promises inside persistToolUsage. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + describe("tool-logger", () => { beforeEach(() => { vi.clearAllMocks(); + mockToolUsageEventCreate.mockResolvedValue({}); + mockDetectResource.mockReturnValue(null); + mockResolveProjectUuid.mockResolvedValue(null); }); describe("truncateParams", () => { @@ -192,4 +224,162 @@ describe("tool-logger", () => { expect((logObj.params.content as string)).toContain("..."); }); }); + + describe("ToolUsageEvent persistence", () => { + it("persists a row with entity + projectUuid from detectResource on successful call", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue({ + entityType: "task", + entityUuid: "task-1", + projectUuid: "project-1", + }); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const handler = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); + server.registerTool("chorus_get_task", {}, handler); + + await server.callTool("chorus_get_task", { taskUuid: "task-1" }); + await flushMicrotasks(); + + expect(mockToolUsageEventCreate).toHaveBeenCalledOnce(); + const call = mockToolUsageEventCreate.mock.calls[0][0]; + expect(call.data.companyUuid).toBe("company-1"); + expect(call.data.agentUuid).toBe("agent-1"); + expect(call.data.toolName).toBe("chorus_get_task"); + expect(call.data.source).toBe("mcp"); + expect(call.data.isError).toBe(false); + expect(call.data.errorText).toBeNull(); + expect(call.data.entityType).toBe("task"); + expect(call.data.entityUuid).toBe("task-1"); + expect(call.data.projectUuid).toBe("project-1"); + expect(typeof call.data.durationMs).toBe("number"); + expect(typeof call.data.inputSize).toBe("number"); + expect(typeof call.data.outputSize).toBe("number"); + // resolveProjectUuid should NOT be called when params already carry projectUuid + expect(mockResolveProjectUuid).not.toHaveBeenCalled(); + }); + + it("falls back to resolveProjectUuid when resource carries no projectUuid", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue({ + entityType: "task", + entityUuid: "task-2", + }); + mockResolveProjectUuid.mockResolvedValue("project-resolved"); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const handler = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); + server.registerTool("chorus_update_task", {}, handler); + + await server.callTool("chorus_update_task", { taskUuid: "task-2" }); + await flushMicrotasks(); + + expect(mockResolveProjectUuid).toHaveBeenCalledOnce(); + expect(mockResolveProjectUuid.mock.calls[0][0]).toBe("task"); + expect(mockResolveProjectUuid.mock.calls[0][1]).toBe("task-2"); + expect(mockToolUsageEventCreate.mock.calls[0][0].data.projectUuid).toBe("project-resolved"); + }); + + it("persists errorText and isError=true on business rejection", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue(null); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const handler = vi.fn().mockResolvedValue({ + isError: true, + content: [{ type: "text", text: "Task already claimed" }], + }); + server.registerTool("chorus_claim_task", {}, handler); + + await server.callTool("chorus_claim_task", { taskUuid: "t-1" }); + await flushMicrotasks(); + + expect(mockToolUsageEventCreate).toHaveBeenCalledOnce(); + const data = mockToolUsageEventCreate.mock.calls[0][0].data; + expect(data.isError).toBe(true); + expect(data.errorText).toBe("Task already claimed"); + expect(data.entityType).toBeNull(); + expect(data.entityUuid).toBeNull(); + expect(data.projectUuid).toBeNull(); + }); + + it("writes null entity fields when detectResource returns null", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue(null); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const handler = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); + server.registerTool("chorus_list_projects", {}, handler); + + await server.callTool("chorus_list_projects", {}); + await flushMicrotasks(); + + const data = mockToolUsageEventCreate.mock.calls[0][0].data; + expect(data.entityType).toBeNull(); + expect(data.entityUuid).toBeNull(); + expect(data.projectUuid).toBeNull(); + expect(mockResolveProjectUuid).not.toHaveBeenCalled(); + }); + + it("extracts sessionUuid from params when provided", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue(null); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const handler = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); + server.registerTool("chorus_report_work", {}, handler); + + await server.callTool("chorus_report_work", { + taskUuid: "t-1", + report: "progress", + sessionUuid: "session-xyz", + }); + await flushMicrotasks(); + + expect(mockToolUsageEventCreate.mock.calls[0][0].data.sessionUuid).toBe("session-xyz"); + }); + + it("does not block or alter the tool response when persistence rejects", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue(null); + mockToolUsageEventCreate.mockRejectedValueOnce(new Error("db down")); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const response = { content: [{ type: "text", text: "ok" }] }; + const handler = vi.fn().mockResolvedValue(response); + server.registerTool("chorus_get_task", {}, handler); + + const result = await server.callTool("chorus_get_task", { taskUuid: "t-1" }); + expect(result).toBe(response); + + await flushMicrotasks(); + + // A warn log should record the persistence failure — no silent errors. + const persistWarn = mockWarn.mock.calls.find( + (c) => c[1] === "Failed to persist ToolUsageEvent" + ); + expect(persistWarn).toBeDefined(); + }); + + it("does not await persistence before returning tool result (fire-and-forget)", async () => { + const server = createMockServer(); + mockDetectResource.mockReturnValue(null); + + let resolvePersist: (v: unknown) => void = () => {}; + mockToolUsageEventCreate.mockImplementation( + () => new Promise((r) => { resolvePersist = r; }) + ); + enableToolCallLogging(server as unknown as Parameters[0], mockAuth); + + const handler = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }); + server.registerTool("chorus_get_task", {}, handler); + + // Tool must resolve even though persistence promise is still pending. + const result = await server.callTool("chorus_get_task", { taskUuid: "t-1" }); + expect(result).toBeDefined(); + + // Now resolve the pending persistence so the test doesn't leak. + resolvePersist({}); + }); + }); }); diff --git a/src/mcp/tools/tool-logger.ts b/src/mcp/tools/tool-logger.ts index 65339712..7ebfac77 100644 --- a/src/mcp/tools/tool-logger.ts +++ b/src/mcp/tools/tool-logger.ts @@ -6,7 +6,9 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { AgentAuthContext } from "@/types/auth"; +import { prisma } from "@/lib/prisma"; import logger from "@/lib/logger"; +import { detectResource, resolveProjectUuid } from "./presence"; const toolLogger = logger.child({ module: "mcp-tool" }); @@ -42,16 +44,89 @@ function extractErrorText(result: unknown): string | undefined { return undefined; } +/** Safely compute byte size of a JSON-serializable value (returns 0 on failure). */ +function safeJsonSize(value: unknown): number { + try { + return JSON.stringify(value)?.length ?? 0; + } catch { + return 0; + } +} + +/** Extract sessionUuid from tool params, if present as a string. */ +function extractSessionUuid(params: Record): string | null { + return typeof params.sessionUuid === "string" ? params.sessionUuid : null; +} + +interface PersistParams { + auth: AgentAuthContext; + toolName: string; + params: Record; + result: unknown; + durationMs: number; + isError: boolean; + errorText: string | null; + projectUuidCache: Map; +} + +/** + * Fire-and-forget persistence of a ToolUsageEvent row. + * Must never throw — callers rely on this not blocking tool response. + */ +async function persistToolUsage(p: PersistParams): Promise { + const resource = detectResource(p.params, p.toolName); + + let entityType: string | null = null; + let entityUuid: string | null = null; + let projectUuid: string | null = null; + + if (resource) { + entityType = resource.entityType; + entityUuid = resource.entityUuid; + projectUuid = resource.projectUuid ?? null; + if (!projectUuid) { + projectUuid = await resolveProjectUuid( + resource.entityType, + resource.entityUuid, + p.projectUuidCache + ); + } + } + + await prisma.toolUsageEvent.create({ + data: { + companyUuid: p.auth.companyUuid, + agentUuid: p.auth.actorUuid, + sessionUuid: extractSessionUuid(p.params), + toolName: p.toolName, + source: "mcp", + durationMs: p.durationMs, + inputSize: safeJsonSize(p.params), + outputSize: safeJsonSize(p.result), + isError: p.isError, + errorText: p.errorText, + entityType, + entityUuid, + projectUuid, + }, + }); +} + /** * Wraps a McpServer to log all tool calls. * - Business rejections (isError: true) → warn * - Successful calls → debug * - Unhandled exceptions → error + re-throw * + * Also persists each call to ToolUsageEvent asynchronously (fire-and-forget). + * * Call BEFORE enablePresence so this wrapper is the outermost layer. */ export function enableToolCallLogging(server: McpServer, auth: AgentAuthContext): void { const agent = { uuid: auth.actorUuid, name: auth.agentName || "Unknown Agent" }; + // Session-scoped cache for projectUuid resolution (shared across all tool calls + // for this MCP session), mirrors the pattern used in presence.ts enablePresence. + const projectUuidCache = new Map(); const originalRegisterTool = server.registerTool.bind(server); server.registerTool = function (name: string, config: unknown, handler: unknown) { @@ -73,9 +148,11 @@ export function enableToolCallLogging(server: McpServer, auth: AgentAuthContext) } const durationMs = Date.now() - start; + const isErrorResult = + typeof result === "object" && result !== null && (result as { isError?: boolean }).isError === true; + const errorText = isErrorResult ? extractErrorText(result) : undefined; - if (typeof result === "object" && result !== null && (result as { isError?: boolean }).isError) { - const errorText = extractErrorText(result); + if (isErrorResult) { toolLogger.warn( { tool: name, agent, params: truncateParams(params), error: errorText, durationMs }, "MCP tool business rejection" @@ -87,6 +164,20 @@ export function enableToolCallLogging(server: McpServer, auth: AgentAuthContext) ); } + // Fire-and-forget persistence — never block the tool response. + persistToolUsage({ + auth, + toolName: name, + params, + result, + durationMs, + isError: isErrorResult, + errorText: errorText ?? null, + projectUuidCache, + }).catch((err) => { + toolLogger.warn({ tool: name, err }, "Failed to persist ToolUsageEvent"); + }); + return result; }; @@ -95,4 +186,4 @@ export function enableToolCallLogging(server: McpServer, auth: AgentAuthContext) } // Exported for testing -export { truncateParams, extractErrorText }; +export { truncateParams, extractErrorText, safeJsonSize, extractSessionUuid }; diff --git a/src/services/__tests__/observability.service.test.ts b/src/services/__tests__/observability.service.test.ts new file mode 100644 index 00000000..c3eb4862 --- /dev/null +++ b/src/services/__tests__/observability.service.test.ts @@ -0,0 +1,592 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ===== Prisma mock ===== +const mockPrisma = vi.hoisted(() => ({ + toolUsageEvent: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + agentSession: { + findMany: vi.fn(), + findFirst: vi.fn(), + }, + tokenUsageRecord: { + findMany: vi.fn(), + createMany: vi.fn(), + deleteMany: vi.fn(), + }, + idea: { + findMany: vi.fn(), + }, + proposal: { + findMany: vi.fn(), + }, + task: { + findMany: vi.fn(), + }, + agent: { + findMany: vi.fn(), + }, +})); +vi.mock("@/lib/prisma", () => ({ prisma: mockPrisma })); + +import { + getEntityTokens, + getIdeaLifecycleTokens, + getProposalTokens, + getAgentObservability, + batchInsertClientToolEvents, + classifyPhase, + resolveProjectUuids, + insertAttributedTokenUsage, +} from "@/services/observability.service"; + +// ===== Helpers ===== +const companyUuid = "company-0000-0000-0000-000000000001"; +const agentUuid = "agent-0000-0000-0000-000000000001"; +const sessionUuidA = "session-a"; +const sessionUuidB = "session-b"; +const ideaUuid = "idea-0000-0000-0000-000000000001"; +const proposalUuid = "proposal-0000-0000-0000-000000000001"; +const taskUuidA = "task-a"; +const taskUuidB = "task-b"; +const projectUuid = "project-0000-0000-0000-000000000001"; + +type Event = { + toolName: string; + isError: boolean; + durationMs: number; + inputSize: number; + outputSize: number; + sessionUuid: string | null; + entityType?: string | null; + entityUuid?: string | null; + agentUuid?: string; + createdAt?: Date; +}; + +function makeEvent(overrides: Partial = {}): Event { + return { + toolName: "chorus_get_task", + isError: false, + durationMs: 10, + inputSize: 100, + outputSize: 200, + sessionUuid: sessionUuidA, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockPrisma.tokenUsageRecord.findMany.mockResolvedValue([]); +}); + +// ===== classifyPhase ===== +describe("classifyPhase", () => { + it("maps elaboration tools", () => { + expect(classifyPhase("chorus_pm_start_elaboration")).toBe("elaboration"); + expect(classifyPhase("chorus_answer_elaboration")).toBe("elaboration"); + expect(classifyPhase("chorus_get_elaboration")).toBe("elaboration"); + }); + + it("maps proposal-phase tools", () => { + expect(classifyPhase("chorus_pm_create_proposal")).toBe("proposal"); + expect(classifyPhase("chorus_pm_add_task_draft")).toBe("proposal"); + expect(classifyPhase("chorus_pm_submit_proposal")).toBe("proposal"); + expect(classifyPhase("chorus_get_proposal")).toBe("proposal"); + }); + + it("maps review-phase tools", () => { + expect(classifyPhase("chorus_admin_approve_proposal")).toBe("review"); + expect(classifyPhase("chorus_admin_reject_proposal")).toBe("review"); + }); + + it("maps verify-phase tools", () => { + expect(classifyPhase("chorus_submit_for_verify")).toBe("verify"); + expect(classifyPhase("chorus_admin_verify_task")).toBe("verify"); + expect(classifyPhase("chorus_admin_reopen_task")).toBe("verify"); + }); + + it("returns null for non-phase tools", () => { + expect(classifyPhase("chorus_get_task")).toBe(null); + expect(classifyPhase("chorus_update_task")).toBe(null); + }); +}); + +// ===== getEntityTokens ===== +describe("getEntityTokens", () => { + it("aggregates tool events and sums token usage from TokenUsageRecord", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + makeEvent({ toolName: "chorus_get_task", inputSize: 100, outputSize: 200, durationMs: 10 }), + makeEvent({ toolName: "chorus_update_task", inputSize: 50, outputSize: 80, durationMs: 5 }), + makeEvent({ toolName: "chorus_get_task", inputSize: 30, outputSize: 40, durationMs: 2, isError: true }), + ]); + mockPrisma.tokenUsageRecord.findMany.mockResolvedValue([ + { + inputTokens: 1000, outputTokens: 500, cacheCreationInputTokens: 0, cacheReadInputTokens: 200, + sessionUuid: sessionUuidA, + }, + ]); + + const result = await getEntityTokens(companyUuid, "task", taskUuidA); + + expect(result.toolCallCount).toBe(3); + expect(result.toolErrorCount).toBe(1); + expect(result.totalInputSize).toBe(180); + expect(result.totalOutputSize).toBe(320); + expect(result.totalDurationMs).toBe(17); + expect(result.toolBreakdown).toHaveLength(2); + expect(result.toolBreakdown[0].toolName).toBe("chorus_get_task"); + expect(result.toolBreakdown[0].callCount).toBe(2); + expect(result.toolBreakdown[0].errorCount).toBe(1); + expect(result.sessionTokens).toEqual({ + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 200, + }); + expect(result.sessionCount).toBe(1); + }); + + it("skips session lookup when no events have sessionUuid", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + makeEvent({ sessionUuid: null }), + ]); + + const result = await getEntityTokens(companyUuid, "idea", ideaUuid); + expect(mockPrisma.agentSession.findMany).not.toHaveBeenCalled(); + expect(result.sessionCount).toBe(0); + expect(result.sessionTokens.input_tokens).toBe(0); + }); + + it("sums token records and counts distinct sessions", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + makeEvent({ sessionUuid: sessionUuidA }), + makeEvent({ sessionUuid: sessionUuidA }), + makeEvent({ sessionUuid: sessionUuidB }), + ]); + mockPrisma.tokenUsageRecord.findMany.mockResolvedValue([ + { inputTokens: 100, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, sessionUuid: sessionUuidA }, + { inputTokens: 50, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, sessionUuid: sessionUuidB }, + ]); + + const result = await getEntityTokens(companyUuid, "task", taskUuidA); + + expect(result.sessionTokens.input_tokens).toBe(150); + expect(result.sessionCount).toBe(2); + }); + + it("handles missing/invalid tokenUsage safely", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + makeEvent({ sessionUuid: sessionUuidA }), + ]); + mockPrisma.agentSession.findMany.mockResolvedValue([ + { tokenUsage: null }, + { tokenUsage: { input_tokens: "bogus" } }, + ]); + + const result = await getEntityTokens(companyUuid, "task", taskUuidA); + expect(result.sessionTokens.input_tokens).toBe(0); + }); + + it("returns empty aggregation when no events exist", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([]); + const result = await getEntityTokens(companyUuid, "document", "doc-uuid"); + expect(result.toolCallCount).toBe(0); + expect(result.toolBreakdown).toEqual([]); + expect(mockPrisma.agentSession.findMany).not.toHaveBeenCalled(); + }); +}); + +// ===== getIdeaLifecycleTokens ===== +describe("getIdeaLifecycleTokens", () => { + it("aggregates events across idea/proposal/tasks and buckets by phase", async () => { + mockPrisma.proposal.findMany.mockResolvedValue([ + { uuid: proposalUuid, inputUuids: [ideaUuid] }, + { uuid: "other-proposal", inputUuids: ["other-idea"] }, + ]); + mockPrisma.task.findMany.mockResolvedValue([ + { uuid: taskUuidA, title: "Task A", status: "done" }, + { uuid: taskUuidB, title: "Task B", status: "in_progress" }, + ]); + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + // elaboration on the idea + makeEvent({ toolName: "chorus_pm_start_elaboration", entityType: "idea", entityUuid: ideaUuid, sessionUuid: sessionUuidA }), + makeEvent({ toolName: "chorus_answer_elaboration", entityType: "idea", entityUuid: ideaUuid, sessionUuid: sessionUuidA }), + // proposal drafting + makeEvent({ toolName: "chorus_pm_add_task_draft", entityType: "proposal", entityUuid: proposalUuid, sessionUuid: sessionUuidA }), + // review + makeEvent({ toolName: "chorus_admin_approve_proposal", entityType: "proposal", entityUuid: proposalUuid, sessionUuid: sessionUuidB }), + // execution on task A + makeEvent({ toolName: "chorus_get_task", entityType: "task", entityUuid: taskUuidA, sessionUuid: sessionUuidB }), + // verify on task B + makeEvent({ toolName: "chorus_submit_for_verify", entityType: "task", entityUuid: taskUuidB, sessionUuid: sessionUuidB }), + ]); + // First call: entity-level token records + mockPrisma.tokenUsageRecord.findMany.mockResolvedValueOnce([ + { entityType: "idea", entityUuid: ideaUuid, inputTokens: 100, outputTokens: 50, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + { entityType: "task", entityUuid: taskUuidA, inputTokens: 200, outputTokens: 80, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + ]); + // Second call: project-level token records + mockPrisma.tokenUsageRecord.findMany.mockResolvedValueOnce([]); + + const result = await getIdeaLifecycleTokens(companyUuid, ideaUuid); + + expect(result.ideaUuid).toBe(ideaUuid); + expect(result.phases).toHaveLength(5); + const byPhase = Object.fromEntries(result.phases.map((p) => [p.phase, p])); + expect(byPhase.elaboration.toolCallCount).toBe(2); + expect(byPhase.proposal.toolCallCount).toBe(1); + expect(byPhase.review.toolCallCount).toBe(1); + expect(byPhase.execution.toolCallCount).toBe(1); + expect(byPhase.verify.toolCallCount).toBe(1); + + // idea entity tokens appear in elaboration phase + expect(byPhase.elaboration.sessionTokens.input_tokens).toBe(100); + + // Tasks + expect(result.tasks).toHaveLength(2); + const byTask = Object.fromEntries(result.tasks.map((t) => [t.taskUuid, t])); + expect(byTask[taskUuidA].toolCallCount).toBe(1); + expect(byTask[taskUuidA].sessionTokens.input_tokens).toBe(200); + expect(byTask[taskUuidB].toolCallCount).toBe(1); + + // Totals: idea + task A = 300 input tokens + expect(result.totals.sessionTokens.input_tokens).toBe(300); + expect(result.totals.toolCallCount).toBe(6); + }); + + it("uses entity fallback for non-discriminating tool names", async () => { + mockPrisma.proposal.findMany.mockResolvedValue([]); + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + makeEvent({ toolName: "chorus_get_idea", entityType: "idea", entityUuid: ideaUuid, sessionUuid: null }), + ]); + mockPrisma.agentSession.findMany.mockResolvedValue([]); + + const result = await getIdeaLifecycleTokens(companyUuid, ideaUuid); + const elab = result.phases.find((p) => p.phase === "elaboration")!; + expect(elab.toolCallCount).toBe(1); + }); + + it("handles idea with no proposals/tasks", async () => { + mockPrisma.proposal.findMany.mockResolvedValue([]); + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([]); + mockPrisma.agentSession.findMany.mockResolvedValue([]); + + const result = await getIdeaLifecycleTokens(companyUuid, ideaUuid); + expect(result.phases.every((p) => p.toolCallCount === 0)).toBe(true); + expect(result.tasks).toEqual([]); + expect(mockPrisma.task.findMany).not.toHaveBeenCalled(); + }); + + it("filters proposals by inputUuids containing the ideaUuid", async () => { + mockPrisma.proposal.findMany.mockResolvedValue([ + { uuid: "p1", inputUuids: [ideaUuid] }, + { uuid: "p2", inputUuids: ["other"] }, + { uuid: "p3", inputUuids: null }, + ]); + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([]); + mockPrisma.agentSession.findMany.mockResolvedValue([]); + + await getIdeaLifecycleTokens(companyUuid, ideaUuid); + const callArg = mockPrisma.task.findMany.mock.calls[0][0]; + expect(callArg.where.proposalUuid.in).toEqual(["p1"]); + }); +}); + +// ===== getProposalTokens ===== +describe("getProposalTokens", () => { + it("splits drafting vs review events", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + makeEvent({ toolName: "chorus_pm_add_task_draft", sessionUuid: sessionUuidA, inputSize: 100, outputSize: 50 }), + makeEvent({ toolName: "chorus_pm_submit_proposal", sessionUuid: sessionUuidA, inputSize: 20, outputSize: 30 }), + makeEvent({ toolName: "chorus_admin_approve_proposal", sessionUuid: sessionUuidB, inputSize: 10, outputSize: 40 }), + makeEvent({ toolName: "chorus_admin_reject_proposal", sessionUuid: sessionUuidB, inputSize: 15, outputSize: 25 }), + ]); + mockPrisma.tokenUsageRecord.findMany.mockResolvedValue([ + { inputTokens: 500, outputTokens: 250, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }, + ]); + + const result = await getProposalTokens(companyUuid, proposalUuid); + + expect(result.proposalUuid).toBe(proposalUuid); + expect(result.drafting.toolCallCount).toBe(2); + expect(result.drafting.totalInputSize).toBe(120); + expect(result.drafting.totalOutputSize).toBe(80); + expect(result.review.toolCallCount).toBe(2); + expect(result.review.totalInputSize).toBe(25); + expect(result.review.totalOutputSize).toBe(65); + expect(result.drafting.sessionTokens.input_tokens).toBe(500); + }); + + it("returns zeroed aggregations when no events", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([]); + const result = await getProposalTokens(companyUuid, proposalUuid); + expect(result.drafting.toolCallCount).toBe(0); + expect(result.review.toolCallCount).toBe(0); + expect(result.drafting.sessionTokens.input_tokens).toBe(0); + }); +}); + +// ===== getAgentObservability ===== +describe("getAgentObservability", () => { + it("groups events by agent with tokens and daily series", async () => { + const day1 = new Date("2026-04-17T10:00:00Z"); + const day2 = new Date("2026-04-18T10:00:00Z"); + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + { + agentUuid: "agent-1", + toolName: "chorus_get_task", + isError: false, + durationMs: 5, + inputSize: 10, + outputSize: 20, + sessionUuid: sessionUuidA, + createdAt: day1, + }, + { + agentUuid: "agent-1", + toolName: "chorus_get_task", + isError: true, + durationMs: 3, + inputSize: 5, + outputSize: 8, + sessionUuid: sessionUuidA, + createdAt: day2, + }, + { + agentUuid: "agent-2", + toolName: "chorus_update_task", + isError: false, + durationMs: 8, + inputSize: 40, + outputSize: 60, + sessionUuid: null, + createdAt: day1, + }, + ]); + mockPrisma.agent.findMany.mockResolvedValue([ + { uuid: "agent-1", name: "Alpha" }, + { uuid: "agent-2", name: "Beta" }, + ]); + mockPrisma.tokenUsageRecord.findMany.mockResolvedValue([ + { agentUuid: "agent-1", inputTokens: 500, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, sessionUuid: sessionUuidA }, + ]); + + const result = await getAgentObservability(companyUuid, projectUuid, 7); + + expect(result.dateRange.days).toBe(7); + expect(result.agents).toHaveLength(2); + expect(result.agents[0].agentUuid).toBe("agent-1"); + expect(result.agents[0].agentName).toBe("Alpha"); + expect(result.agents[0].toolCallCount).toBe(2); + expect(result.agents[0].toolErrorCount).toBe(1); + expect(result.agents[0].sessionCount).toBe(1); + expect(result.agents[0].sessionTokens.input_tokens).toBe(500); + expect(result.agents[0].dailySeries).toEqual([ + { date: "2026-04-17", toolCallCount: 1 }, + { date: "2026-04-18", toolCallCount: 1 }, + ]); + expect(result.agents[1].agentUuid).toBe("agent-2"); + expect(result.agents[1].sessionCount).toBe(0); + }); + + it("handles agent with unknown name gracefully", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([ + { + agentUuid: "agent-unknown", + toolName: "chorus_get_task", + isError: false, + durationMs: 1, + inputSize: 1, + outputSize: 1, + sessionUuid: null, + createdAt: new Date("2026-04-18T00:00:00Z"), + }, + ]); + mockPrisma.agent.findMany.mockResolvedValue([]); + + const result = await getAgentObservability(companyUuid, projectUuid, 30); + expect(result.agents[0].agentName).toBe("Unknown Agent"); + }); + + it("returns empty agents list when no events", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([]); + mockPrisma.agent.findMany.mockResolvedValue([]); + + const result = await getAgentObservability(companyUuid, projectUuid, 30); + expect(result.agents).toEqual([]); + expect(mockPrisma.agent.findMany).not.toHaveBeenCalled(); + }); + + it("scopes the query by company, project and time window", async () => { + mockPrisma.toolUsageEvent.findMany.mockResolvedValue([]); + mockPrisma.agent.findMany.mockResolvedValue([]); + mockPrisma.agentSession.findMany.mockResolvedValue([]); + + await getAgentObservability(companyUuid, projectUuid, 7); + const call = mockPrisma.toolUsageEvent.findMany.mock.calls[0][0]; + expect(call.where.companyUuid).toBe(companyUuid); + expect(call.where.projectUuid).toBe(projectUuid); + expect(call.where.createdAt.gte).toBeInstanceOf(Date); + }); +}); + +// ===== batchInsertClientToolEvents ===== +describe("batchInsertClientToolEvents", () => { + it("inserts mapped rows with source=client", async () => { + mockPrisma.toolUsageEvent.createMany.mockResolvedValue({ count: 2 }); + + const result = await batchInsertClientToolEvents( + companyUuid, + agentUuid, + sessionUuidA, + [ + { + tool: "Bash", + input_len: 100, + output_len: 200, + entity_type: "task", + entity_uuid: taskUuidA, + project_uuid: projectUuid, + ts: "2026-04-18T00:00:00Z", + }, + { tool: "Read" }, + ] + ); + + expect(result.inserted).toBe(2); + const args = mockPrisma.toolUsageEvent.createMany.mock.calls[0][0]; + expect(args.data).toHaveLength(2); + expect(args.data[0]).toMatchObject({ + companyUuid, + agentUuid, + sessionUuid: sessionUuidA, + toolName: "Bash", + source: "client", + inputSize: 100, + outputSize: 200, + entityType: "task", + entityUuid: taskUuidA, + projectUuid, + }); + expect(args.data[0].createdAt).toEqual(new Date("2026-04-18T00:00:00Z")); + expect(args.data[1]).toMatchObject({ + toolName: "Read", + inputSize: 0, + outputSize: 0, + entityType: null, + entityUuid: null, + projectUuid: null, + }); + }); + + it("returns 0 and skips DB call when events is empty", async () => { + const result = await batchInsertClientToolEvents( + companyUuid, + agentUuid, + sessionUuidA, + [] + ); + expect(result.inserted).toBe(0); + expect(mockPrisma.toolUsageEvent.createMany).not.toHaveBeenCalled(); + }); + + it("propagates is_error and error_text flags", async () => { + mockPrisma.toolUsageEvent.createMany.mockResolvedValue({ count: 1 }); + await batchInsertClientToolEvents(companyUuid, agentUuid, null, [ + { tool: "Bash", is_error: true, error_text: "boom" }, + ]); + const args = mockPrisma.toolUsageEvent.createMany.mock.calls[0][0]; + expect(args.data[0].isError).toBe(true); + expect(args.data[0].errorText).toBe("boom"); + expect(args.data[0].sessionUuid).toBe(null); + }); +}); + +// ===== resolveProjectUuids ===== +describe("resolveProjectUuids", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resolves idea → projectUuid directly", async () => { + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.idea.findMany.mockResolvedValue([{ uuid: ideaUuid, projectUuid }]); + mockPrisma.proposal.findMany.mockResolvedValue([]); + + const records = [{ entityType: "idea", entityUuid: ideaUuid }] as Parameters[1]; + const result = await resolveProjectUuids(companyUuid, records); + expect(result.get(ideaUuid)).toBe(projectUuid); + }); + + it("resolves proposal → projectUuid directly", async () => { + mockPrisma.task.findMany.mockResolvedValue([]); + mockPrisma.idea.findMany.mockResolvedValue([]); + mockPrisma.proposal.findMany.mockResolvedValue([{ uuid: proposalUuid, projectUuid }]); + + const records = [{ entityType: "proposal", entityUuid: proposalUuid }] as Parameters[1]; + const result = await resolveProjectUuids(companyUuid, records); + expect(result.get(proposalUuid)).toBe(projectUuid); + }); + + it("resolves task → proposal → projectUuid via join", async () => { + mockPrisma.task.findMany.mockResolvedValue([{ uuid: taskUuidA, proposalUuid }]); + mockPrisma.idea.findMany.mockResolvedValue([]); + // Only one proposal.findMany call: task→proposalUuid lookup (direct proposal lookup skipped since no proposal entity) + mockPrisma.proposal.findMany.mockResolvedValue([{ uuid: proposalUuid, projectUuid }]); + + const records = [{ entityType: "task", entityUuid: taskUuidA }] as Parameters[1]; + const result = await resolveProjectUuids(companyUuid, records); + expect(result.get(taskUuidA)).toBe(projectUuid); + }); + + it("returns empty map for records without entities", async () => { + const records = [{ entityType: null, entityUuid: null }] as Parameters[1]; + const result = await resolveProjectUuids(companyUuid, records); + expect(result.size).toBe(0); + }); +}); + +// ===== insertAttributedTokenUsage ===== +describe("insertAttributedTokenUsage", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns zero for empty records", async () => { + const result = await insertAttributedTokenUsage([]); + expect(result).toEqual({ inserted: 0 }); + expect(mockPrisma.tokenUsageRecord.createMany).not.toHaveBeenCalled(); + }); + + it("deletes old records by sourceSessionId before inserting", async () => { + mockPrisma.tokenUsageRecord.deleteMany.mockResolvedValue({ count: 1 }); + mockPrisma.tokenUsageRecord.createMany.mockResolvedValue({ count: 2 }); + + const records = [ + { sourceSessionId: "sess-1", companyUuid, agentUuid, sessionUuid: null, projectUuid: null, entityType: "task", entityUuid: taskUuidA, inputTokens: 10, outputTokens: 20, cacheCreationInputTokens: 30, cacheReadInputTokens: 40, isReviewer: false, turnTimestamp: null }, + { sourceSessionId: "sess-1", companyUuid, agentUuid, sessionUuid: null, projectUuid: null, entityType: "idea", entityUuid: ideaUuid, inputTokens: 5, outputTokens: 15, cacheCreationInputTokens: 25, cacheReadInputTokens: 35, isReviewer: false, turnTimestamp: null }, + ] as Parameters[0]; + + const result = await insertAttributedTokenUsage(records); + expect(mockPrisma.tokenUsageRecord.deleteMany).toHaveBeenCalledWith({ + where: { sourceSessionId: { in: ["sess-1"] } }, + }); + expect(mockPrisma.tokenUsageRecord.createMany).toHaveBeenCalledWith({ data: records }); + expect(result).toEqual({ inserted: 2 }); + }); + + it("skips delete when no sourceSessionId", async () => { + mockPrisma.tokenUsageRecord.createMany.mockResolvedValue({ count: 1 }); + + const records = [ + { sourceSessionId: null, companyUuid, agentUuid, sessionUuid: "s1", projectUuid: null, entityType: "task", entityUuid: taskUuidA, inputTokens: 10, outputTokens: 20, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, isReviewer: false, turnTimestamp: null }, + ] as Parameters[0]; + + const result = await insertAttributedTokenUsage(records); + expect(mockPrisma.tokenUsageRecord.deleteMany).not.toHaveBeenCalled(); + expect(result).toEqual({ inserted: 1 }); + }); +}); + diff --git a/src/services/__tests__/token-attribution.test.ts b/src/services/__tests__/token-attribution.test.ts new file mode 100644 index 00000000..4f1f4c7d --- /dev/null +++ b/src/services/__tests__/token-attribution.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { + attributeTokenUsage, + findPrimaryEntity, + type TurnUsage, + type TimelineEntry, +} from "@/services/observability.service"; + +const companyUuid = "company-0000"; +const agentUuid = "agent-0000"; +const sessionUuid = "session-0000"; + +const taskA = "task-aaaa"; +const taskB = "task-bbbb"; +const ideaC = "idea-cccc"; +const proposalD = "proposal-dddd"; + +function turn(ts: string, output: number, input = 0, cacheRead = 0, cacheCreate = 0): TurnUsage { + return { + ts, + input_tokens: input, + output_tokens: output, + cache_read_input_tokens: cacheRead, + cache_creation_input_tokens: cacheCreate, + }; +} + +function tl(ts: string, entityType: string, entityUuid: string): TimelineEntry { + return { ts, entity_type: entityType, entity_uuid: entityUuid }; +} + +describe("findPrimaryEntity", () => { + it("returns null for empty timeline", () => { + expect(findPrimaryEntity([])).toBeNull(); + }); + + it("picks task over proposal over idea over document", () => { + const timeline = [ + tl("2026-04-20T01:00:00Z", "idea", ideaC), + tl("2026-04-20T01:01:00Z", "proposal", proposalD), + tl("2026-04-20T01:02:00Z", "document", "doc-0000"), + ]; + const result = findPrimaryEntity(timeline); + expect(result?.entity_type).toBe("proposal"); + expect(result?.entity_uuid).toBe(proposalD); + }); + + it("picks task when present alongside idea", () => { + const timeline = [ + tl("2026-04-20T01:00:00Z", "idea", ideaC), + tl("2026-04-20T01:01:00Z", "task", taskA), + ]; + const result = findPrimaryEntity(timeline); + expect(result?.entity_type).toBe("task"); + expect(result?.entity_uuid).toBe(taskA); + }); + + it("returns the first entity with highest priority when tied", () => { + const timeline = [ + tl("2026-04-20T01:00:00Z", "task", taskA), + tl("2026-04-20T01:01:00Z", "task", taskB), + ]; + const result = findPrimaryEntity(timeline); + expect(result?.entity_uuid).toBe(taskA); + }); +}); + +describe("attributeTokenUsage", () => { + it("returns empty array for empty turns", () => { + const result = attributeTokenUsage([], [], sessionUuid, agentUuid, companyUuid); + expect(result).toEqual([]); + }); + + // --- Sub-agent (sessionUuid set): all turns → primary entity --- + + it("sub-agent: all turns attributed to primary entity", () => { + const turns = [ + turn("2026-04-20T01:00:10Z", 500, 10000, 5000, 200), + turn("2026-04-20T01:01:00Z", 300, 8000, 4000, 100), + ]; + const timeline = [ + tl("2026-04-20T01:00:05Z", "task", taskA), + ]; + + const result = attributeTokenUsage(turns, timeline, sessionUuid, agentUuid, companyUuid); + + expect(result).toHaveLength(2); + expect(result[0].entityType).toBe("task"); + expect(result[0].entityUuid).toBe(taskA); + expect(result[0].inputTokens).toBe(10000); + expect(result[0].outputTokens).toBe(500); + expect(result[1].entityType).toBe("task"); + expect(result[1].entityUuid).toBe(taskA); + expect(result[1].inputTokens).toBe(8000); + }); + + it("sub-agent: turns before first timeline entry also get primary entity", () => { + const turns = [ + turn("2026-04-20T01:00:00Z", 100, 15000), + turn("2026-04-20T01:05:00Z", 200), + ]; + const timeline = [ + tl("2026-04-20T01:03:00Z", "task", taskA), + ]; + + const result = attributeTokenUsage(turns, timeline, sessionUuid, agentUuid, companyUuid); + + expect(result).toHaveLength(2); + expect(result[0].entityType).toBe("task"); + expect(result[0].entityUuid).toBe(taskA); + expect(result[0].inputTokens).toBe(15000); + expect(result[1].entityType).toBe("task"); + expect(result[1].entityUuid).toBe(taskA); + }); + + it("sub-agent reviewer: proposal is primary even when idea is read first", () => { + const turns = [ + turn("2026-04-20T01:00:00Z", 100, 20000), + turn("2026-04-20T01:01:00Z", 200, 15000), + turn("2026-04-20T01:02:00Z", 300, 10000), + ]; + const timeline = [ + tl("2026-04-20T01:00:30Z", "idea", ideaC), + tl("2026-04-20T01:01:30Z", "proposal", proposalD), + ]; + + const result = attributeTokenUsage(turns, timeline, sessionUuid, agentUuid, companyUuid); + + expect(result).toHaveLength(3); + for (const r of result) { + expect(r.entityType).toBe("proposal"); + expect(r.entityUuid).toBe(proposalD); + } + expect(result[0].inputTokens).toBe(20000); + expect(result[1].inputTokens).toBe(15000); + expect(result[2].inputTokens).toBe(10000); + }); + + it("sub-agent: empty timeline → null entity for all turns", () => { + const turns = [ + turn("2026-04-20T01:00:10Z", 500, 10000), + ]; + + const result = attributeTokenUsage(turns, [], sessionUuid, agentUuid, companyUuid); + + expect(result).toHaveLength(1); + expect(result[0].entityType).toBeNull(); + expect(result[0].entityUuid).toBeNull(); + expect(result[0].inputTokens).toBe(10000); + }); + + // --- Main agent (sessionUuid=null): carry-forward per turn --- + + it("main agent: carry-forward across timeline entries", () => { + const turns = [ + turn("2026-04-20T01:00:10Z", 100), + turn("2026-04-20T01:00:30Z", 200), + turn("2026-04-20T01:01:30Z", 300), + ]; + const timeline = [ + tl("2026-04-20T01:00:05Z", "task", taskA), + tl("2026-04-20T01:01:00Z", "idea", ideaC), + ]; + + const result = attributeTokenUsage(turns, timeline, null, agentUuid, companyUuid); + + expect(result).toHaveLength(3); + expect(result[0].entityUuid).toBe(taskA); + expect(result[1].entityUuid).toBe(taskA); + expect(result[2].entityUuid).toBe(ideaC); + }); + + it("main agent: turns before first timeline entry get null entity", () => { + const turns = [ + turn("2026-04-20T01:00:00Z", 100, 15000), + turn("2026-04-20T01:05:00Z", 200), + ]; + const timeline = [ + tl("2026-04-20T01:03:00Z", "task", taskA), + ]; + + const result = attributeTokenUsage(turns, timeline, null, agentUuid, companyUuid); + + expect(result).toHaveLength(2); + expect(result[0].entityType).toBeNull(); + expect(result[0].entityUuid).toBeNull(); + expect(result[1].entityType).toBe("task"); + expect(result[1].entityUuid).toBe(taskA); + }); + + it("main agent: splits turns across multiple entities by timeline", () => { + const turns = [ + turn("2026-04-20T01:00:10Z", 400), + turn("2026-04-20T01:02:10Z", 600), + ]; + const timeline = [ + tl("2026-04-20T01:00:05Z", "task", taskA), + tl("2026-04-20T01:01:00Z", "task", taskB), + ]; + + const result = attributeTokenUsage(turns, timeline, null, agentUuid, companyUuid); + + expect(result).toHaveLength(2); + expect(result[0].entityUuid).toBe(taskA); + expect(result[1].entityUuid).toBe(taskB); + }); + + // --- Common behaviors --- + + it("includes zero output_tokens turns (they still have input)", () => { + const turns = [ + turn("2026-04-20T01:00:10Z", 0, 5000), + ]; + const timeline = [ + tl("2026-04-20T01:00:05Z", "task", taskA), + ]; + + const result = attributeTokenUsage(turns, timeline, sessionUuid, agentUuid, companyUuid); + + expect(result).toHaveLength(1); + expect(result[0].entityType).toBe("task"); + expect(result[0].inputTokens).toBe(5000); + expect(result[0].outputTokens).toBe(0); + }); + + it("preserves sessionUuid on all records, projectUuid null (resolved server-side)", () => { + const turns = [turn("2026-04-20T01:00:10Z", 100, 200)]; + const timeline = [tl("2026-04-20T01:00:05Z", "task", taskA)]; + + const result = attributeTokenUsage(turns, timeline, sessionUuid, agentUuid, companyUuid); + + for (const r of result) { + expect(r.companyUuid).toBe(companyUuid); + expect(r.agentUuid).toBe(agentUuid); + expect(r.sessionUuid).toBe(sessionUuid); + expect(r.projectUuid).toBeNull(); + } + }); + + it("sets sourceSessionId and turnTimestamp for dedup", () => { + const turns = [turn("2026-04-20T01:00:10Z", 100, 200)]; + + const result = attributeTokenUsage(turns, [], null, agentUuid, companyUuid, "cc-session-123"); + + expect(result).toHaveLength(1); + expect(result[0].sourceSessionId).toBe("cc-session-123"); + expect(result[0].turnTimestamp).toEqual(new Date("2026-04-20T01:00:10Z")); + }); + + it("main agent: no timeline and no entity → null", () => { + const turns = [ + turn("2026-04-20T01:00:00Z", 100, 5000), + ]; + const result = attributeTokenUsage(turns, [], null, agentUuid, companyUuid); + + expect(result).toHaveLength(1); + expect(result[0].inputTokens).toBe(5000); + expect(result[0].outputTokens).toBe(100); + expect(result[0].entityType).toBeNull(); + }); +}); diff --git a/src/services/observability.service.ts b/src/services/observability.service.ts new file mode 100644 index 00000000..4cd26fc8 --- /dev/null +++ b/src/services/observability.service.ts @@ -0,0 +1,988 @@ +// src/services/observability.service.ts +// Observability Service Layer — aggregation over ToolUsageEvent + TokenUsageRecord +// UUID-Based Architecture: All operations use UUIDs and are scoped by companyUuid. + +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; + +// ===== Type Definitions ===== + +export type EntityType = "task" | "idea" | "proposal" | "document"; +export type LifecyclePhase = "elaboration" | "proposal" | "review" | "execution" | "verify"; + +export interface TokenUsage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; +} + +export interface ToolBreakdownItem { + toolName: string; + callCount: number; + errorCount: number; + totalInputSize: number; + totalOutputSize: number; + totalDurationMs: number; +} + +export interface EntityTokensResult { + entityType: EntityType; + entityUuid: string; + toolCallCount: number; + toolErrorCount: number; + totalInputSize: number; + totalOutputSize: number; + totalDurationMs: number; + toolBreakdown: ToolBreakdownItem[]; + sessionTokens: TokenUsage; + sessionCount: number; +} + +export interface LifecyclePhaseResult { + phase: LifecyclePhase; + toolCallCount: number; + toolErrorCount: number; + totalInputSize: number; + totalOutputSize: number; + sessionTokens: TokenUsage; + toolBreakdown: ToolBreakdownItem[]; +} + +export interface IdeaLifecycleResult { + ideaUuid: string; + totals: { + toolCallCount: number; + sessionTokens: TokenUsage; + }; + phases: LifecyclePhaseResult[]; + tasks: Array<{ + taskUuid: string; + title: string; + status: string; + toolCallCount: number; + totalInputSize: number; + totalOutputSize: number; + sessionTokens: TokenUsage; + }>; +} + +export interface ProposalTokensResult { + proposalUuid: string; + drafting: { + toolCallCount: number; + totalInputSize: number; + totalOutputSize: number; + sessionTokens: TokenUsage; + toolBreakdown: ToolBreakdownItem[]; + }; + review: { + toolCallCount: number; + totalInputSize: number; + totalOutputSize: number; + }; +} + +export interface AgentObservabilityItem { + agentUuid: string; + agentName: string; + toolCallCount: number; + toolErrorCount: number; + totalInputSize: number; + totalOutputSize: number; + sessionTokens: TokenUsage; + sessionCount: number; + dailySeries: Array<{ date: string; toolCallCount: number }>; + topTools: ToolBreakdownItem[]; +} + +export interface AgentObservabilityResult { + projectUuid: string; + dateRange: { days: number; from: string; to: string }; + agents: AgentObservabilityItem[]; +} + +export interface ClientToolEventInput { + tool: string; + id?: string; + agent?: string; + ts?: string; + input_len?: number; + output_len?: number; + entity_type?: string | null; + entity_uuid?: string | null; + project_uuid?: string | null; + is_error?: boolean; + error_text?: string | null; +} + +// ===== Helpers ===== + +function emptyTokenUsage(): TokenUsage { + return { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }; +} + +function addTokenUsage(target: TokenUsage, raw: unknown): void { + if (!raw || typeof raw !== "object") return; + const v = raw as Record; + const add = (k: keyof TokenUsage) => { + const n = v[k]; + if (typeof n === "number" && Number.isFinite(n)) target[k] += n; + }; + add("input_tokens"); + add("output_tokens"); + add("cache_creation_input_tokens"); + add("cache_read_input_tokens"); +} + +type RawToolEvent = { + toolName: string; + isError: boolean; + durationMs: number; + inputSize: number; + outputSize: number; +}; + +function aggregateTools(events: RawToolEvent[]): { + callCount: number; + errorCount: number; + totalInputSize: number; + totalOutputSize: number; + totalDurationMs: number; + breakdown: ToolBreakdownItem[]; +} { + const byTool = new Map(); + let callCount = 0; + let errorCount = 0; + let totalInputSize = 0; + let totalOutputSize = 0; + let totalDurationMs = 0; + + for (const ev of events) { + callCount += 1; + if (ev.isError) errorCount += 1; + totalInputSize += ev.inputSize; + totalOutputSize += ev.outputSize; + totalDurationMs += ev.durationMs; + + const item = byTool.get(ev.toolName) ?? { + toolName: ev.toolName, + callCount: 0, + errorCount: 0, + totalInputSize: 0, + totalOutputSize: 0, + totalDurationMs: 0, + }; + item.callCount += 1; + if (ev.isError) item.errorCount += 1; + item.totalInputSize += ev.inputSize; + item.totalOutputSize += ev.outputSize; + item.totalDurationMs += ev.durationMs; + byTool.set(ev.toolName, item); + } + + const breakdown = Array.from(byTool.values()).sort( + (a, b) => b.callCount - a.callCount + ); + + return { + callCount, + errorCount, + totalInputSize, + totalOutputSize, + totalDurationMs, + breakdown, + }; +} + +// Classify MCP tool name into a lifecycle phase for an Idea. +// Returns null when the tool is not phase-discriminating on its own. +// Order matters: review/verify checks must come before the generic "proposal" +// substring fallback, because e.g. "chorus_admin_approve_proposal" also contains +// the word "proposal" but belongs to the review phase. +export function classifyPhase(toolName: string): LifecyclePhase | null { + if (toolName.includes("elaboration")) return "elaboration"; + if ( + toolName === "chorus_admin_approve_proposal" || + toolName === "chorus_admin_reject_proposal" || + toolName === "chorus_admin_close_proposal" + ) { + return "review"; + } + if ( + toolName === "chorus_admin_verify_task" || + toolName === "chorus_admin_reopen_task" || + toolName === "chorus_submit_for_verify" || + toolName === "chorus_report_criteria_self_check" + ) { + return "verify"; + } + if ( + toolName.includes("proposal") || + toolName === "chorus_pm_add_document_draft" || + toolName === "chorus_pm_update_document_draft" || + toolName === "chorus_pm_remove_document_draft" || + toolName === "chorus_pm_add_task_draft" || + toolName === "chorus_pm_update_task_draft" || + toolName === "chorus_pm_remove_task_draft" + ) { + return "proposal"; + } + return null; +} + +// ===== Service Methods ===== + +// Aggregate tool events + session tokens for a single entity. +export async function getEntityTokens( + companyUuid: string, + entityType: EntityType, + entityUuid: string +): Promise { + const events = await prisma.toolUsageEvent.findMany({ + where: { companyUuid, entityType, entityUuid }, + select: { + toolName: true, + isError: true, + durationMs: true, + inputSize: true, + outputSize: true, + sessionUuid: true, + }, + }); + + const agg = aggregateTools(events); + + // Token usage from TokenUsageRecord (T3 will optimize this query) + const sessionTokens = emptyTokenUsage(); + let sessionCount = 0; + const tokenRecords = await prisma.tokenUsageRecord.findMany({ + where: { companyUuid, entityType, entityUuid }, + select: { + inputTokens: true, + outputTokens: true, + cacheCreationInputTokens: true, + cacheReadInputTokens: true, + sessionUuid: true, + }, + }); + const sessionSet = new Set(); + for (const r of tokenRecords) { + sessionTokens.input_tokens += r.inputTokens; + sessionTokens.output_tokens += r.outputTokens; + sessionTokens.cache_creation_input_tokens += r.cacheCreationInputTokens; + sessionTokens.cache_read_input_tokens += r.cacheReadInputTokens; + if (r.sessionUuid) sessionSet.add(r.sessionUuid); + } + sessionCount = sessionSet.size; + + return { + entityType, + entityUuid, + toolCallCount: agg.callCount, + toolErrorCount: agg.errorCount, + totalInputSize: agg.totalInputSize, + totalOutputSize: agg.totalOutputSize, + totalDurationMs: agg.totalDurationMs, + toolBreakdown: agg.breakdown, + sessionTokens, + sessionCount, + }; +} + +// Aggregate lifecycle phases for an Idea. Uses ToolUsageEvent for the +// Idea itself (phases: elaboration/proposal/review) plus the tasks that the +// Idea spawned (phases: execution/verify). +export async function getIdeaLifecycleTokens( + companyUuid: string, + ideaUuid: string +): Promise { + // 1. Find linked proposals (inputType=idea, inputUuids contains ideaUuid) and tasks. + const proposals = await prisma.proposal.findMany({ + where: { companyUuid, inputType: "idea" }, + select: { uuid: true, inputUuids: true }, + }); + const linkedProposalUuids = proposals + .filter((p) => { + const arr = Array.isArray(p.inputUuids) ? (p.inputUuids as unknown[]) : []; + return arr.includes(ideaUuid); + }) + .map((p) => p.uuid); + + const tasks = linkedProposalUuids.length + ? await prisma.task.findMany({ + where: { companyUuid, proposalUuid: { in: linkedProposalUuids } }, + select: { uuid: true, title: true, status: true }, + }) + : []; + const taskUuids = tasks.map((t) => t.uuid); + + // 2. Collect events across idea + proposals + tasks. + const orClauses: Prisma.ToolUsageEventWhereInput[] = [ + { entityType: "idea", entityUuid: ideaUuid }, + ]; + if (linkedProposalUuids.length) { + orClauses.push({ + entityType: "proposal", + entityUuid: { in: linkedProposalUuids }, + }); + } + if (taskUuids.length) { + orClauses.push({ entityType: "task", entityUuid: { in: taskUuids } }); + } + + const events = await prisma.toolUsageEvent.findMany({ + where: { companyUuid, OR: orClauses }, + select: { + toolName: true, + isError: true, + durationMs: true, + inputSize: true, + outputSize: true, + sessionUuid: true, + entityType: true, + entityUuid: true, + }, + }); + + // 3. Bucket events into lifecycle phases. + const phaseBuckets: Record = { + elaboration: [], + proposal: [], + review: [], + execution: [], + verify: [], + }; + const taskEventsByUuid = new Map(); + + for (const ev of events) { + const raw: RawToolEvent = { + toolName: ev.toolName, + isError: ev.isError, + durationMs: ev.durationMs, + inputSize: ev.inputSize, + outputSize: ev.outputSize, + }; + + let phase: LifecyclePhase | null = classifyPhase(ev.toolName); + // Fallback by entity context when tool name is not discriminating. + if (!phase) { + if (ev.entityType === "idea") phase = "elaboration"; + else if (ev.entityType === "proposal") phase = "proposal"; + else if (ev.entityType === "task") phase = "execution"; + } + if (phase) { + phaseBuckets[phase].push(raw); + } + + if (ev.entityType === "task" && ev.entityUuid) { + const list = taskEventsByUuid.get(ev.entityUuid) ?? []; + list.push(raw); + taskEventsByUuid.set(ev.entityUuid, list); + } + } + + // 4. Fetch token usage from TokenUsageRecord, indexed by entityType:entityUuid. + const allEntityUuids = [ideaUuid, ...linkedProposalUuids, ...taskUuids]; + const tokenRecords = allEntityUuids.length + ? await prisma.tokenUsageRecord.findMany({ + where: { companyUuid, entityUuid: { in: allEntityUuids } }, + select: { + entityType: true, + entityUuid: true, + sessionUuid: true, + isReviewer: true, + inputTokens: true, + outputTokens: true, + cacheCreationInputTokens: true, + cacheReadInputTokens: true, + }, + }) + : []; + + // Also fetch project-level records for input tokens + const projectTokenRecords = await prisma.tokenUsageRecord.findMany({ + where: { + companyUuid, + entityType: "project", + entityUuid: { in: allEntityUuids.length ? allEntityUuids : ["__none__"] }, + }, + select: { + inputTokens: true, + outputTokens: true, + cacheCreationInputTokens: true, + cacheReadInputTokens: true, + }, + }); + + const tokenByEntity = new Map(); + for (const r of tokenRecords) { + const key = `${r.entityType}:${r.entityUuid}`; + const existing = tokenByEntity.get(key) ?? emptyTokenUsage(); + existing.input_tokens += r.inputTokens; + existing.output_tokens += r.outputTokens; + existing.cache_creation_input_tokens += r.cacheCreationInputTokens; + existing.cache_read_input_tokens += r.cacheReadInputTokens; + tokenByEntity.set(key, existing); + } + + // 5. Build phase results using entityType + isReviewer. + // | entityType | sessionUuid | isReviewer | → phase | + // |------------|-------------|------------|--------------| + // | idea | * | * | elaboration | + // | proposal | null | * | proposal | + // | proposal | set | * | review | + // | task | null | * | verify | + // | task | set | true | verify | + // | task | set | false | execution | + const phaseTokenBuckets: Record = { + elaboration: emptyTokenUsage(), + proposal: emptyTokenUsage(), + review: emptyTokenUsage(), + execution: emptyTokenUsage(), + verify: emptyTokenUsage(), + }; + for (const r of tokenRecords) { + const usage: TokenUsage = { + input_tokens: r.inputTokens, + output_tokens: r.outputTokens, + cache_creation_input_tokens: r.cacheCreationInputTokens, + cache_read_input_tokens: r.cacheReadInputTokens, + }; + if (r.entityType === "idea") { + addTokenUsage(phaseTokenBuckets.elaboration, usage); + } else if (r.entityType === "proposal") { + if (r.sessionUuid) { + addTokenUsage(phaseTokenBuckets.review, usage); + } else { + addTokenUsage(phaseTokenBuckets.proposal, usage); + } + } else if (r.entityType === "task") { + if (r.isReviewer) { + addTokenUsage(phaseTokenBuckets.verify, usage); + } else { + addTokenUsage(phaseTokenBuckets.execution, usage); + } + } + } + + const phases: LifecyclePhaseResult[] = ( + ["elaboration", "proposal", "review", "execution", "verify"] as LifecyclePhase[] + ).map((phase) => { + const agg = aggregateTools(phaseBuckets[phase]); + return { + phase, + toolCallCount: agg.callCount, + toolErrorCount: agg.errorCount, + totalInputSize: agg.totalInputSize, + totalOutputSize: agg.totalOutputSize, + sessionTokens: phaseTokenBuckets[phase], + toolBreakdown: agg.breakdown, + }; + }); + + // 6. Per-task rollup. + const perTask = tasks.map((t) => { + const evs = taskEventsByUuid.get(t.uuid) ?? []; + const agg = aggregateTools(evs); + const tokens = tokenByEntity.get(`task:${t.uuid}`) ?? emptyTokenUsage(); + return { + taskUuid: t.uuid, + title: t.title, + status: t.status, + toolCallCount: agg.callCount, + totalInputSize: agg.totalInputSize, + totalOutputSize: agg.totalOutputSize, + sessionTokens: { ...tokens }, + }; + }); + + // 7. Totals: sum all token records for this idea's entities + project-level input. + const totalTokens = emptyTokenUsage(); + for (const t of tokenByEntity.values()) { + addTokenUsage(totalTokens, t); + } + for (const r of projectTokenRecords) { + totalTokens.input_tokens += r.inputTokens; + totalTokens.output_tokens += r.outputTokens; + totalTokens.cache_creation_input_tokens += r.cacheCreationInputTokens; + totalTokens.cache_read_input_tokens += r.cacheReadInputTokens; + } + const totalToolCalls = phases.reduce((acc, p) => acc + p.toolCallCount, 0); + + return { + ideaUuid, + totals: { + toolCallCount: totalToolCalls, + sessionTokens: totalTokens, + }, + phases, + tasks: perTask, + }; +} + +// Proposal-specific breakdown: drafting (events on proposal entity) + review rounds. +export async function getProposalTokens( + companyUuid: string, + proposalUuid: string +): Promise { + const events = await prisma.toolUsageEvent.findMany({ + where: { companyUuid, entityType: "proposal", entityUuid: proposalUuid }, + select: { + toolName: true, + isError: true, + durationMs: true, + inputSize: true, + outputSize: true, + sessionUuid: true, + }, + }); + + const draftingEvents: RawToolEvent[] = []; + const reviewEvents: RawToolEvent[] = []; + + for (const ev of events) { + const phase = classifyPhase(ev.toolName); + const raw: RawToolEvent = { + toolName: ev.toolName, + isError: ev.isError, + durationMs: ev.durationMs, + inputSize: ev.inputSize, + outputSize: ev.outputSize, + }; + if (phase === "review") { + reviewEvents.push(raw); + } else { + draftingEvents.push(raw); + } + } + + const draftingAgg = aggregateTools(draftingEvents); + const reviewAgg = aggregateTools(reviewEvents); + + const draftingTokens = emptyTokenUsage(); + const proposalTokenRecords = await prisma.tokenUsageRecord.findMany({ + where: { companyUuid, entityType: "proposal", entityUuid: proposalUuid }, + select: { + inputTokens: true, + outputTokens: true, + cacheCreationInputTokens: true, + cacheReadInputTokens: true, + }, + }); + for (const r of proposalTokenRecords) { + draftingTokens.input_tokens += r.inputTokens; + draftingTokens.output_tokens += r.outputTokens; + draftingTokens.cache_creation_input_tokens += r.cacheCreationInputTokens; + draftingTokens.cache_read_input_tokens += r.cacheReadInputTokens; + } + + return { + proposalUuid, + drafting: { + toolCallCount: draftingAgg.callCount, + totalInputSize: draftingAgg.totalInputSize, + totalOutputSize: draftingAgg.totalOutputSize, + sessionTokens: draftingTokens, + toolBreakdown: draftingAgg.breakdown, + }, + review: { + toolCallCount: reviewAgg.callCount, + totalInputSize: reviewAgg.totalInputSize, + totalOutputSize: reviewAgg.totalOutputSize, + }, + }; +} + +// Agent observability dashboard for a project over the given date range. +export async function getAgentObservability( + companyUuid: string, + projectUuid: string, + days: number +): Promise { + const now = new Date(); + const from = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + + const events = await prisma.toolUsageEvent.findMany({ + where: { + companyUuid, + projectUuid, + createdAt: { gte: from }, + }, + select: { + agentUuid: true, + toolName: true, + isError: true, + durationMs: true, + inputSize: true, + outputSize: true, + sessionUuid: true, + createdAt: true, + }, + }); + + type AgentBucket = { + events: RawToolEvent[]; + sessions: Set; + dailyCounts: Map; + }; + const byAgent = new Map(); + for (const ev of events) { + const bucket = byAgent.get(ev.agentUuid) ?? { + events: [], + sessions: new Set(), + dailyCounts: new Map(), + }; + bucket.events.push({ + toolName: ev.toolName, + isError: ev.isError, + durationMs: ev.durationMs, + inputSize: ev.inputSize, + outputSize: ev.outputSize, + }); + if (ev.sessionUuid) bucket.sessions.add(ev.sessionUuid); + const dayKey = ev.createdAt.toISOString().slice(0, 10); + bucket.dailyCounts.set(dayKey, (bucket.dailyCounts.get(dayKey) ?? 0) + 1); + byAgent.set(ev.agentUuid, bucket); + } + + const agentUuids = Array.from(byAgent.keys()); + const [agents, tokenRecords] = await Promise.all([ + agentUuids.length + ? prisma.agent.findMany({ + where: { companyUuid, uuid: { in: agentUuids } }, + select: { uuid: true, name: true }, + }) + : Promise.resolve([] as Array<{ uuid: string; name: string }>), + agentUuids.length + ? prisma.tokenUsageRecord.findMany({ + where: { companyUuid, projectUuid, agentUuid: { in: agentUuids } }, + select: { + agentUuid: true, + inputTokens: true, + outputTokens: true, + cacheCreationInputTokens: true, + cacheReadInputTokens: true, + sessionUuid: true, + }, + }) + : Promise.resolve( + [] as Array<{ + agentUuid: string; + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + sessionUuid: string | null; + }> + ), + ]); + + const agentNameByUuid = new Map(agents.map((a) => [a.uuid, a.name])); + const tokensByAgent = new Map }>(); + for (const r of tokenRecords) { + const entry = tokensByAgent.get(r.agentUuid) ?? { + tokens: emptyTokenUsage(), + sessions: new Set(), + }; + entry.tokens.input_tokens += r.inputTokens; + entry.tokens.output_tokens += r.outputTokens; + entry.tokens.cache_creation_input_tokens += r.cacheCreationInputTokens; + entry.tokens.cache_read_input_tokens += r.cacheReadInputTokens; + if (r.sessionUuid) entry.sessions.add(r.sessionUuid); + tokensByAgent.set(r.agentUuid, entry); + } + + const items: AgentObservabilityItem[] = []; + for (const [agentUuid, bucket] of byAgent.entries()) { + const agg = aggregateTools(bucket.events); + const agentTokenEntry = tokensByAgent.get(agentUuid); + const tokens = agentTokenEntry?.tokens ?? emptyTokenUsage(); + const dailySeries = Array.from(bucket.dailyCounts.entries()) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([date, toolCallCount]) => ({ date, toolCallCount })); + items.push({ + agentUuid, + agentName: agentNameByUuid.get(agentUuid) ?? "Unknown Agent", + toolCallCount: agg.callCount, + toolErrorCount: agg.errorCount, + totalInputSize: agg.totalInputSize, + totalOutputSize: agg.totalOutputSize, + sessionTokens: { ...tokens }, + sessionCount: agentTokenEntry?.sessions.size ?? bucket.sessions.size, + dailySeries, + topTools: agg.breakdown.slice(0, 10), + }); + } + + items.sort((a, b) => b.toolCallCount - a.toolCallCount); + + return { + projectUuid, + dateRange: { + days, + from: from.toISOString(), + to: now.toISOString(), + }, + agents: items, + }; +} + +// Bulk insert client-reported (Layer 2) tool events. +// Caller must have already authorized the agent and verified sessionUuid ownership. +export async function batchInsertClientToolEvents( + companyUuid: string, + agentUuid: string, + sessionUuid: string | null, + events: ClientToolEventInput[] +): Promise<{ inserted: number }> { + if (events.length === 0) return { inserted: 0 }; + + const rows = events.map((e) => ({ + companyUuid, + agentUuid, + sessionUuid, + toolName: e.tool, + source: "client", + durationMs: 0, + inputSize: Number.isFinite(e.input_len) ? Number(e.input_len ?? 0) : 0, + outputSize: Number.isFinite(e.output_len) ? Number(e.output_len ?? 0) : 0, + isError: Boolean(e.is_error), + errorText: e.error_text ?? null, + entityType: e.entity_type ?? null, + entityUuid: e.entity_uuid ?? null, + projectUuid: e.project_uuid ?? null, + createdAt: e.ts ? new Date(e.ts) : new Date(), + })); + + const result = await prisma.toolUsageEvent.createMany({ data: rows }); + return { inserted: result.count }; +} + +// ===== Token Attribution Engine ===== + +export interface TurnUsage { + ts: string; + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +} + +export interface TimelineEntry { + ts: string; + entity_type: string; + entity_uuid: string; +} + +interface AttributedRecord { + companyUuid: string; + agentUuid: string; + sessionUuid: string | null; + projectUuid: string | null; + entityType: string | null; + entityUuid: string | null; + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + isReviewer: boolean; + sourceSessionId: string | null; + turnTimestamp: Date | null; +} + +// Per-turn attribution: each turn becomes one record with entity attribution. +// Uses carry-forward: the last timeline entry before a turn's timestamp determines +// the active entity. +export function attributeTokenUsage( + turns: TurnUsage[], + timeline: TimelineEntry[], + sessionUuid: string | null, + agentUuid: string, + companyUuid: string, + sourceSessionId: string | null = null, + isReviewer: boolean = false +): AttributedRecord[] { + if (turns.length === 0) return []; + + const sortedTimeline = [...timeline].sort( + (a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime() + ); + + // Sub-agents are spawned for a single primary entity (a task, proposal, etc.). + // They may call chorus tools on other entities for context (e.g. reviewer reads + // the idea before reviewing the proposal), but ALL their tokens should be + // attributed to the primary entity. Pick the highest-priority entity from the + // timeline: task > proposal > idea > document. + const primaryEntity = sessionUuid + ? findPrimaryEntity(sortedTimeline) + : null; + + const records: AttributedRecord[] = []; + + for (const turn of turns) { + const turnTs = turn.ts ? new Date(turn.ts) : null; + // Sub-agent: all turns → primary entity. Main agent: carry-forward per turn. + const entity = sessionUuid + ? primaryEntity + : turn.ts + ? findActiveEntity(turn.ts, sortedTimeline) + : null; + + records.push({ + companyUuid, + agentUuid, + sessionUuid, + projectUuid: null, + entityType: entity?.entity_type ?? null, + entityUuid: entity?.entity_uuid ?? null, + inputTokens: turn.input_tokens ?? 0, + outputTokens: turn.output_tokens ?? 0, + cacheCreationInputTokens: turn.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: turn.cache_read_input_tokens ?? 0, + isReviewer, + sourceSessionId, + turnTimestamp: turnTs, + }); + } + + return records; +} + +// Resolve projectUuid for each entity in attributed records. +// Batch-queries all unique entity UUIDs, returns a map of entityUuid -> projectUuid. +// Records without entity get null projectUuid. +export async function resolveProjectUuids( + companyUuid: string, + records: AttributedRecord[] +): Promise> { + const result = new Map(); + + const taskUuids = new Set(); + const ideaUuids = new Set(); + const proposalUuids = new Set(); + + for (const r of records) { + if (!r.entityType || !r.entityUuid) continue; + if (r.entityType === "task") taskUuids.add(r.entityUuid); + else if (r.entityType === "idea") ideaUuids.add(r.entityUuid); + else if (r.entityType === "proposal") proposalUuids.add(r.entityUuid); + } + + const [tasks, ideas, proposals] = await Promise.all([ + taskUuids.size + ? prisma.task.findMany({ + where: { companyUuid, uuid: { in: [...taskUuids] } }, + select: { uuid: true, proposalUuid: true }, + }) + : Promise.resolve([]), + ideaUuids.size + ? prisma.idea.findMany({ + where: { companyUuid, uuid: { in: [...ideaUuids] } }, + select: { uuid: true, projectUuid: true }, + }) + : Promise.resolve([]), + proposalUuids.size + ? prisma.proposal.findMany({ + where: { companyUuid, uuid: { in: [...proposalUuids] } }, + select: { uuid: true, projectUuid: true }, + }) + : Promise.resolve([]), + ]); + + for (const idea of ideas) { + if (idea.projectUuid) result.set(idea.uuid, idea.projectUuid); + } + for (const proposal of proposals) { + if (proposal.projectUuid) result.set(proposal.uuid, proposal.projectUuid); + } + + // Tasks need proposal lookup for projectUuid + const taskProposalUuids = new Set(); + for (const task of tasks) { + if (task.proposalUuid) taskProposalUuids.add(task.proposalUuid); + } + const taskProposals = taskProposalUuids.size + ? await prisma.proposal.findMany({ + where: { uuid: { in: [...taskProposalUuids] } }, + select: { uuid: true, projectUuid: true }, + }) + : []; + const proposalProjectMap = new Map( + taskProposals.filter((p) => p.projectUuid).map((p) => [p.uuid, p.projectUuid!]) + ); + for (const task of tasks) { + if (task.proposalUuid) { + const projUuid = proposalProjectMap.get(task.proposalUuid); + if (projUuid) result.set(task.uuid, projUuid); + } + } + + return result; +} + +function findActiveEntity( + turnTs: string, + sortedTimeline: TimelineEntry[] +): TimelineEntry | null { + const turnTime = new Date(turnTs).getTime(); + let best: TimelineEntry | null = null; + for (const entry of sortedTimeline) { + if (new Date(entry.ts).getTime() <= turnTime) { + best = entry; + } else { + break; + } + } + return best; +} + +const ENTITY_PRIORITY: Record = { + task: 4, + proposal: 3, + idea: 2, + document: 1, +}; + +// Sub-agents work on one primary entity. Pick the highest-priority entity type +// seen anywhere in the timeline. A reviewer may read an idea for context but its +// real work target is the proposal. +export function findPrimaryEntity( + timeline: TimelineEntry[] +): TimelineEntry | null { + let best: TimelineEntry | null = null; + let bestPri = 0; + for (const entry of timeline) { + const pri = ENTITY_PRIORITY[entry.entity_type] ?? 0; + if (pri > bestPri) { + bestPri = pri; + best = entry; + } + } + return best; +} + +// Upsert: when sourceSessionId is set, delete old records first (each session = 1 snapshot). +// When sourceSessionId is null, just insert (no dedup possible). +export async function insertAttributedTokenUsage( + records: AttributedRecord[] +): Promise<{ inserted: number }> { + if (records.length === 0) return { inserted: 0 }; + + const sourceIds = new Set( + records.map((r) => r.sourceSessionId).filter((s): s is string => s !== null) + ); + if (sourceIds.size > 0) { + await prisma.tokenUsageRecord.deleteMany({ + where: { sourceSessionId: { in: [...sourceIds] } }, + }); + } + + const result = await prisma.tokenUsageRecord.createMany({ data: records }); + return { inserted: result.count }; +}