mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat(web): add Task Metrics Panel (US-1002)
- Created useTaskMetrics hook to fetch and aggregate telemetry events for a task - Built TaskMetricsPanel component with: - Summary cards for total runs, success rate, duration, tokens, cost - Last run status display - Expandable per-attempt breakdown with full details - Integrated as new 'Metrics' tab in TaskDetailPanel - Fetches via GET /api/telemetry/events/task/:taskId endpoint
This commit is contained in:
parent
65e0c8b278
commit
22509f1808
16 changed files with 2546 additions and 2 deletions
|
|
@ -774,3 +774,78 @@
|
|||
{"type":"task.status_changed","taskId":"task_20260128_wgJpvX","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_mfkHFyCc50om","timestamp":"2026-01-28T13:41:22.770Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_wZ3-Ix","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_mxYYhkOZKc7l","timestamp":"2026-01-28T13:41:36.058Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_XGU2QA","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_YHcC6GUgmzmA","timestamp":"2026-01-28T13:42:00.209Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_o0FPtg","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_2vtNAIZwsFVZ","timestamp":"2026-01-28T13:42:07.347Z"}
|
||||
{"taskId":"test-task-123","type":"run.started","agent":"veritas","model":"opus-4","sessionKey":"session-abc","id":"evt_zw5JjnlgeU4_","timestamp":"2026-01-28T13:42:09.150Z"}
|
||||
{"taskId":"test-task-123","type":"run.completed","agent":"veritas","success":true,"durationMs":5000,"id":"evt_V9hrHDNO7pQW","timestamp":"2026-01-28T13:42:09.207Z"}
|
||||
{"taskId":"test-task-123","type":"run.tokens","agent":"veritas","inputTokens":1000,"outputTokens":500,"cacheTokens":250,"cost":0.05,"id":"evt_-dHg-zmRjYxZ","timestamp":"2026-01-28T13:42:09.268Z"}
|
||||
{"taskId":"test-task-123","type":"run.error","agent":"veritas","error":"Something went wrong","id":"evt_byLvKD3qpdcm","timestamp":"2026-01-28T13:42:09.357Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_wZ3-Ix","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_3GAhnTAcpPmX","timestamp":"2026-01-28T13:42:19.936Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_XGU2QA","project":"veritas-kanban","status":"todo","previousStatus":"in-progress","id":"evt_NppU44PsrvSl","timestamp":"2026-01-28T13:52:08.956Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_eib_Vw","project":"veritas-kanban","status":"todo","previousStatus":"in-progress","id":"evt_oOx2fC4bwVjm","timestamp":"2026-01-28T13:52:08.970Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_XGU2QA","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_xgsI5AYRtd8Z","timestamp":"2026-01-28T13:52:52.733Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_bDBHgJ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_-mZL1L4-6Wmp","timestamp":"2026-01-28T13:52:53.416Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_eib_Vw","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_imBT2bi32sUm","timestamp":"2026-01-28T13:52:53.767Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_jpuZZy","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_72EZbGDKXWw6","timestamp":"2026-01-28T13:52:54.440Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_legacy1","status":"blocked","previousStatus":"review","id":"evt_MMQCGdh6nbOP","timestamp":"2026-01-28T13:54:44.719Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_P274sP","status":"todo","id":"evt_V4_fPnDrNkdZ","timestamp":"2026-01-28T13:54:44.729Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_FL8h2-","status":"todo","id":"evt_uBRFVswvUrBX","timestamp":"2026-01-28T13:54:44.731Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_FL8h2-","status":"in-progress","previousStatus":"todo","id":"evt_yqhjPtcXC6DX","timestamp":"2026-01-28T13:54:44.735Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_jjqn38","status":"todo","id":"evt_tcAKlZxg1U1q","timestamp":"2026-01-28T13:54:44.736Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_jjqn38","status":"blocked","previousStatus":"todo","id":"evt_-ksxyJ7WPXUE","timestamp":"2026-01-28T13:54:44.738Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_3FfL5A","status":"todo","id":"evt_AFPdMBeoOVPE","timestamp":"2026-01-28T13:54:44.739Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_3FfL5A","status":"done","previousStatus":"todo","id":"evt_U9JeibobC_vH","timestamp":"2026-01-28T13:54:44.743Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_GsZS-d","project":"my-project","status":"todo","id":"evt_SicafVIf4AP5","timestamp":"2026-01-28T13:54:44.744Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_xKIdWD","status":"todo","id":"evt_jgnTcbJf1xOt","timestamp":"2026-01-28T13:54:44.750Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_idem1","status":"blocked","previousStatus":"review","id":"evt_pYfmJj2lanhg","timestamp":"2026-01-28T13:54:44.751Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_z51dgv","status":"todo","id":"evt_4WwcjiDCY_my","timestamp":"2026-01-28T13:54:44.755Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Muw7mP","status":"todo","id":"evt_EdaDWWkm1YFh","timestamp":"2026-01-28T13:54:44.760Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi1","status":"blocked","previousStatus":"review","id":"evt_0usdDvjklNMr","timestamp":"2026-01-28T13:54:44.760Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_multi2","status":"blocked","previousStatus":"review","id":"evt_yq3bDdx419Fg","timestamp":"2026-01-28T13:54:44.762Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_Muw7mP","status":"in-progress","previousStatus":"todo","id":"evt_kYHjBrYFmU_I","timestamp":"2026-01-28T13:54:44.773Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_juWdw0","status":"todo","id":"evt_A0rPN_7k-11u","timestamp":"2026-01-28T13:54:44.778Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_xxSVDa","status":"todo","id":"evt_2f4dXGp2BHKI","timestamp":"2026-01-28T13:54:44.784Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_HHkpxq","status":"todo","id":"evt_c8CQAmml5RzQ","timestamp":"2026-01-28T13:54:44.783Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_HUSIsf","status":"todo","id":"evt_Tqs-DPiALCrF","timestamp":"2026-01-28T13:54:44.785Z"}
|
||||
{"type":"task.restored","taskId":"task_20260126_arch1","status":"done","id":"evt_YbeexNpBXj_I","timestamp":"2026-01-28T13:54:44.793Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260126_arch1","status":"blocked","previousStatus":"done","id":"evt_AVTk6XZTJQ2R","timestamp":"2026-01-28T13:54:44.794Z"}
|
||||
{"type":"task.archived","taskId":"task_20260126_arch1","status":"blocked","id":"evt_TYDaM4ZsWdhQ","timestamp":"2026-01-28T13:54:44.797Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_0PP7XE","status":"todo","id":"evt_R3YnyexmXP7w","timestamp":"2026-01-28T13:54:44.806Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_0PP7XE","status":"todo","id":"evt_4Sk3PExoF_5-","timestamp":"2026-01-28T13:54:44.809Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ZihfSY","status":"todo","id":"evt_lO53-1K_Y1Gl","timestamp":"2026-01-28T13:54:44.809Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_FAZ2aW","status":"todo","id":"evt_XVfno9CprXTL","timestamp":"2026-01-28T13:54:44.812Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_5vOofK","status":"todo","id":"evt_OohMbm6im6ll","timestamp":"2026-01-28T13:54:44.825Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_glBVGZ","status":"todo","id":"evt_m0iHuu83bqJQ","timestamp":"2026-01-28T13:54:44.838Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Gl5v7L","project":"test-project","status":"todo","id":"evt_cxXxv3r6E9eM","timestamp":"2026-01-28T13:54:44.846Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_mkn1q9","status":"todo","id":"evt_yT2HNE2pXehw","timestamp":"2026-01-28T13:54:44.846Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_5Ld3rx","status":"todo","id":"evt_kNUjJmZlRcNM","timestamp":"2026-01-28T13:54:44.851Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_LpcXex","status":"todo","id":"evt_z98-A3fuXyEM","timestamp":"2026-01-28T13:54:44.851Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_KQTB-l","status":"todo","id":"evt_V0LzHNmrmQfQ","timestamp":"2026-01-28T13:54:44.853Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_FDB5IE","status":"todo","id":"evt_PBpUq054my1n","timestamp":"2026-01-28T13:54:44.860Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_HzbGiL","status":"todo","id":"evt_jYwFPUQxdbsa","timestamp":"2026-01-28T13:54:44.866Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_U6UTUJ","status":"todo","id":"evt_kimPwDnQbDvD","timestamp":"2026-01-28T13:54:44.866Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_6hqwkY","status":"todo","id":"evt_7zXoVwGrqsdL","timestamp":"2026-01-28T13:54:44.867Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_HzbGiL","status":"in-progress","previousStatus":"todo","id":"evt_wHgvNPQRTXAQ","timestamp":"2026-01-28T13:54:44.869Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_9ln2t6","status":"todo","id":"evt_Qe0GTtdG1aRj","timestamp":"2026-01-28T13:54:44.879Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_BYOasP","status":"todo","id":"evt_tAL_-XoFVmld","timestamp":"2026-01-28T13:54:44.886Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_uCvDA3","status":"todo","id":"evt_HWr_q8G-Jd7j","timestamp":"2026-01-28T13:54:44.887Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_aq7l1h","status":"todo","id":"evt_1FD8HlLK6fSn","timestamp":"2026-01-28T13:54:44.891Z"}
|
||||
{"type":"task.created","taskId":"task_20260128__YhaNP","status":"todo","id":"evt_imSc1ltPGSug","timestamp":"2026-01-28T13:54:44.895Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_9U90BN","status":"todo","id":"evt_jQrJgiM1JBGH","timestamp":"2026-01-28T13:54:44.898Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_1KU-bl","status":"todo","id":"evt_SbQJX4y4RVOm","timestamp":"2026-01-28T13:54:44.902Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_g8qzC4","status":"todo","id":"evt_wTjquCQjT7P_","timestamp":"2026-01-28T13:54:44.910Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_29RMog","status":"todo","id":"evt_BYS9-acEtUr1","timestamp":"2026-01-28T13:54:44.913Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_VXlARW","status":"todo","id":"evt_JEd-Qcem-2fB","timestamp":"2026-01-28T13:54:44.915Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ydtWPG","status":"todo","id":"evt_RvbgU_BoMaA6","timestamp":"2026-01-28T13:54:44.915Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_uTDmNQ","status":"todo","id":"evt_VivGuIZQGukV","timestamp":"2026-01-28T13:54:44.915Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ZxZ8BP","status":"todo","id":"evt_ObHvYOuGnlkp","timestamp":"2026-01-28T13:54:44.923Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ioXiBT","status":"todo","id":"evt_XoHFLRTczSt5","timestamp":"2026-01-28T13:54:44.928Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_GSSx--","status":"todo","id":"evt_0gdv8NoG3QVH","timestamp":"2026-01-28T13:54:44.930Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_i_cYcU","status":"todo","id":"evt_SvWgCWFtai8X","timestamp":"2026-01-28T13:54:44.934Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_T6LmsI","status":"todo","id":"evt_IHVicNeGjRNq","timestamp":"2026-01-28T13:54:44.946Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_P36Avc","status":"todo","id":"evt_Kh1N1dSuNbPZ","timestamp":"2026-01-28T13:54:44.951Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_WQ0_x8","status":"todo","id":"evt_BNdIYoHY5sIo","timestamp":"2026-01-28T13:54:44.959Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_bKNQ4v","project":"project-a","status":"todo","id":"evt_YeAQ4VUOmeaC","timestamp":"2026-01-28T13:54:44.968Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_eK-iPA","project":"project-a","status":"todo","id":"evt_If0IvUF7OAmW","timestamp":"2026-01-28T13:54:44.968Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_PSE8I7","project":"project-b","status":"todo","id":"evt_yAprRzM4HNCq","timestamp":"2026-01-28T13:54:44.968Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_fgcujz","status":"todo","id":"evt_LQBJCGzoqBI0","timestamp":"2026-01-28T13:54:44.975Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_bDBHgJ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_H7lh5oervWqX","timestamp":"2026-01-28T13:55:36.904Z"}
|
||||
|
|
|
|||
0
scripts/emit-telemetry.sh
Normal file → Executable file
0
scripts/emit-telemetry.sh
Normal file → Executable file
175
scripts/get-session-tokens.sh
Executable file
175
scripts/get-session-tokens.sh
Executable file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# get-session-tokens.sh - Pull token/cost data from Clawdbot sessions
|
||||
#
|
||||
# Usage:
|
||||
# get-session-tokens.sh <sessionKey> <taskId> [agent]
|
||||
# get-session-tokens.sh --emit <sessionKey> <taskId> [agent]
|
||||
#
|
||||
# Arguments:
|
||||
# sessionKey - Clawdbot session key (e.g., "agent:main:subagent:uuid")
|
||||
# taskId - Task ID for the telemetry event
|
||||
# agent - Agent name (defaults to extracted from sessionKey)
|
||||
#
|
||||
# Options:
|
||||
# --emit - Also emit the telemetry event via emit-telemetry.sh
|
||||
# --json - Output JSON (default)
|
||||
# --quiet - Suppress stderr warnings
|
||||
#
|
||||
# Output (JSON):
|
||||
# {"taskId":"...", "agent":"...", "inputTokens":N, "outputTokens":N, "cacheTokens":N, "cost":N, "model":"..."}
|
||||
#
|
||||
# Model Pricing (per 1M tokens, USD):
|
||||
# claude-opus-4-5: input=$15.00, output=$75.00, cache_read=$1.50
|
||||
# claude-sonnet-4: input=$3.00, output=$15.00, cache_read=$0.30
|
||||
# claude-3-5-sonnet: input=$3.00, output=$15.00, cache_read=$0.30
|
||||
# claude-3-5-haiku: input=$0.80, output=$4.00, cache_read=$0.08
|
||||
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
EMIT_TELEMETRY=false
|
||||
QUIET=false
|
||||
|
||||
# Parse flags
|
||||
while [[ "${1:-}" =~ ^-- ]]; do
|
||||
case "$1" in
|
||||
--emit) EMIT_TELEMETRY=true; shift ;;
|
||||
--json) shift ;; # Default, no-op
|
||||
--quiet) QUIET=true; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Args
|
||||
sessionKey="${1:-}"
|
||||
taskId="${2:-}"
|
||||
agent="${3:-}"
|
||||
|
||||
if [[ -z "$sessionKey" ]] || [[ -z "$taskId" ]]; then
|
||||
echo "Usage: get-session-tokens.sh [--emit] <sessionKey> <taskId> [agent]" >&2
|
||||
echo "" >&2
|
||||
echo "Example:" >&2
|
||||
echo " get-session-tokens.sh agent:main:subagent:abc123 task_123 veritas-worker" >&2
|
||||
echo " get-session-tokens.sh --emit agent:main:subagent:abc123 task_123" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract agent name from sessionKey if not provided
|
||||
if [[ -z "$agent" ]]; then
|
||||
# sessionKey format: agent:main:subagent:uuid or agent:main:main
|
||||
if [[ "$sessionKey" =~ ^agent:main:subagent: ]]; then
|
||||
agent="subagent"
|
||||
elif [[ "$sessionKey" == "agent:main:main" ]]; then
|
||||
agent="main"
|
||||
else
|
||||
agent="unknown"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Model pricing function (returns input|output|cache prices)
|
||||
get_model_pricing() {
|
||||
local model="$1"
|
||||
case "$model" in
|
||||
claude-opus-4-5|claude-opus-4-5-*)
|
||||
echo "15.00|75.00|1.50"
|
||||
;;
|
||||
claude-sonnet-4|claude-sonnet-4-*)
|
||||
echo "3.00|15.00|0.30"
|
||||
;;
|
||||
claude-3-5-sonnet|claude-3-5-sonnet-*)
|
||||
echo "3.00|15.00|0.30"
|
||||
;;
|
||||
claude-3-5-haiku|claude-3-5-haiku-*)
|
||||
echo "0.80|4.00|0.08"
|
||||
;;
|
||||
claude-3-haiku|claude-3-haiku-*)
|
||||
echo "0.25|1.25|0.03"
|
||||
;;
|
||||
*)
|
||||
# Default to sonnet pricing for unknown models
|
||||
echo "3.00|15.00|0.30"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Get session data from Clawdbot
|
||||
# Filter out any non-JSON prefix (warnings, etc.) by finding the first {
|
||||
session_data_raw=$(clawdbot sessions --json 2>/dev/null)
|
||||
session_data=$(echo "$session_data_raw" | sed -n '/^{/,$p')
|
||||
if [[ -z "$session_data" ]]; then
|
||||
[[ "$QUIET" == "false" ]] && echo "[get-session-tokens] WARN: Failed to fetch sessions from Clawdbot" >&2
|
||||
# Return zeros as fallback
|
||||
echo "{\"taskId\":\"$taskId\",\"agent\":\"$agent\",\"inputTokens\":0,\"outputTokens\":0,\"cacheTokens\":0,\"cost\":0,\"model\":\"unknown\",\"error\":\"session_fetch_failed\"}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract session by key using jq
|
||||
session=$(echo "$session_data" | jq -r --arg key "$sessionKey" '.sessions[] | select(.key == $key)' 2>/dev/null)
|
||||
|
||||
if [[ -z "$session" ]] || [[ "$session" == "null" ]]; then
|
||||
[[ "$QUIET" == "false" ]] && echo "[get-session-tokens] WARN: Session not found: $sessionKey" >&2
|
||||
# Return zeros as fallback
|
||||
echo "{\"taskId\":\"$taskId\",\"agent\":\"$agent\",\"inputTokens\":0,\"outputTokens\":0,\"cacheTokens\":0,\"cost\":0,\"model\":\"unknown\",\"error\":\"session_not_found\"}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract token data
|
||||
inputTokens=$(echo "$session" | jq -r '.inputTokens // 0')
|
||||
outputTokens=$(echo "$session" | jq -r '.outputTokens // 0')
|
||||
totalTokens=$(echo "$session" | jq -r '.totalTokens // 0')
|
||||
model=$(echo "$session" | jq -r '.model // "unknown"')
|
||||
|
||||
# Ensure numeric values
|
||||
[[ "$inputTokens" == "null" ]] && inputTokens=0
|
||||
[[ "$outputTokens" == "null" ]] && outputTokens=0
|
||||
[[ "$totalTokens" == "null" ]] && totalTokens=0
|
||||
|
||||
# Calculate cache tokens (total - input - output, if positive)
|
||||
# Note: Clawdbot reports totalTokens which includes cache reads
|
||||
cacheTokens=0
|
||||
if [[ "$totalTokens" -gt 0 ]]; then
|
||||
computed=$((totalTokens - inputTokens - outputTokens))
|
||||
[[ $computed -gt 0 ]] && cacheTokens=$computed
|
||||
fi
|
||||
|
||||
# Calculate cost
|
||||
cost=0
|
||||
if [[ "$model" != "unknown" ]] && [[ "$model" != "null" ]]; then
|
||||
# Get prices
|
||||
pricing=$(get_model_pricing "$model")
|
||||
in_price=$(echo "$pricing" | cut -d'|' -f1)
|
||||
out_price=$(echo "$pricing" | cut -d'|' -f2)
|
||||
cache_price=$(echo "$pricing" | cut -d'|' -f3)
|
||||
|
||||
# Cost = (input * input_price + output * output_price + cache * cache_price) / 1,000,000
|
||||
# Use awk for floating point
|
||||
cost=$(awk -v i="$inputTokens" -v ip="$in_price" \
|
||||
-v o="$outputTokens" -v op="$out_price" \
|
||||
-v c="$cacheTokens" -v cp="$cache_price" \
|
||||
'BEGIN { printf "%.6f", (i * ip + o * op + c * cp) / 1000000 }')
|
||||
fi
|
||||
|
||||
# Build output JSON
|
||||
output=$(jq -n \
|
||||
--arg taskId "$taskId" \
|
||||
--arg agent "$agent" \
|
||||
--argjson inputTokens "$inputTokens" \
|
||||
--argjson outputTokens "$outputTokens" \
|
||||
--argjson cacheTokens "$cacheTokens" \
|
||||
--argjson cost "$cost" \
|
||||
--arg model "$model" \
|
||||
'{taskId: $taskId, agent: $agent, inputTokens: $inputTokens, outputTokens: $outputTokens, cacheTokens: $cacheTokens, cost: $cost, model: $model}')
|
||||
|
||||
echo "$output"
|
||||
|
||||
# Optionally emit telemetry
|
||||
if [[ "$EMIT_TELEMETRY" == "true" ]]; then
|
||||
if [[ -x "$SCRIPT_DIR/emit-telemetry.sh" ]]; then
|
||||
"$SCRIPT_DIR/emit-telemetry.sh" tokens "$taskId" "$agent" "$inputTokens" "$outputTokens" "$cacheTokens" "$cost"
|
||||
else
|
||||
[[ "$QUIET" == "false" ]] && echo "[get-session-tokens] WARN: emit-telemetry.sh not found or not executable" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
|
@ -1,4 +1,125 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769608536904_avd2nslq0",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_bDBHgJ",
|
||||
"taskTitle": "US-1004: Token & cost tracking integration",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:55:36.904Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608374441_1knnr2tep",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_jpuZZy",
|
||||
"taskTitle": "US-1002: Task metrics panel",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:52:54.441Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608373767_g12k5z7kd",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_eib_Vw",
|
||||
"taskTitle": "RF-04: Add Comprehensive Test Suite",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:52:53.767Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608373416_m7vjzofwz",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_bDBHgJ",
|
||||
"taskTitle": "US-1004: Token & cost tracking integration",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:52:53.416Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608372734_rax40es7u",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_XGU2QA",
|
||||
"taskTitle": "US-1009: Failure alerts to Teams",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:52:52.734Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608328970_oexka31jc",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_eib_Vw",
|
||||
"taskTitle": "RF-04: Add Comprehensive Test Suite",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "todo"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:52:08.970Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769608328957_b9o7rgjsi",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_XGU2QA",
|
||||
"taskTitle": "US-1009: Failure alerts to Teams",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "todo"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:52:08.957Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769607739953_r3oilswro",
|
||||
"type": "comment_added",
|
||||
"taskId": "task_20260128_wZ3-Ix",
|
||||
"taskTitle": "US-1402: Veritas telemetry reporter",
|
||||
"details": {
|
||||
"author": "Veritas",
|
||||
"preview": "Created scripts/emit-telemetry.sh helper with supp..."
|
||||
},
|
||||
"timestamp": "2026-01-28T13:42:19.953Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769607739937_kuhjcv3wh",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_wZ3-Ix",
|
||||
"taskTitle": "US-1402: Veritas telemetry reporter",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:42:19.937Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769607727363_ax3okl4pi",
|
||||
"type": "comment_added",
|
||||
"taskId": "task_20260128_o0FPtg",
|
||||
"taskTitle": "US-1303: Real-time status hook (useAgentStatus)",
|
||||
"details": {
|
||||
"author": "Veritas",
|
||||
"preview": "Created useRealtimeAgentStatus hook with WebSocket..."
|
||||
},
|
||||
"timestamp": "2026-01-28T13:42:07.363Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769607727347_z6crrtzeu",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_o0FPtg",
|
||||
"taskTitle": "US-1303: Real-time status hook (useAgentStatus)",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T13:42:07.347Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769607720210_eghhexv0t",
|
||||
"type": "status_changed",
|
||||
|
|
|
|||
309
server/src/__tests__/routes/agent-status.test.ts
Normal file
309
server/src/__tests__/routes/agent-status.test.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* Agent Status Route Integration Tests
|
||||
* Tests agent status: GET and POST operations
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import { errorHandler, ValidationError } from '../../middleware/error-handler.js';
|
||||
import { asyncHandler } from '../../middleware/async-handler.js';
|
||||
|
||||
// Type definitions matching the route
|
||||
type AgentStatusState = 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error';
|
||||
|
||||
interface AgentStatus {
|
||||
status: AgentStatusState;
|
||||
activeTask?: {
|
||||
id: string;
|
||||
title?: string;
|
||||
};
|
||||
subAgentCount: number;
|
||||
lastUpdated: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
describe('Agent Status Routes', () => {
|
||||
let app: express.Express;
|
||||
let currentStatus: AgentStatus;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset status for each test
|
||||
currentStatus = {
|
||||
status: 'idle',
|
||||
subAgentCount: 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Create app with test routes
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schema for POST
|
||||
const updateStatusSchema = z.object({
|
||||
status: z.enum(['idle', 'working', 'thinking', 'sub-agent', 'error']).optional(),
|
||||
activeTask: z.object({
|
||||
id: z.string(),
|
||||
title: z.string().optional(),
|
||||
}).optional().nullable(),
|
||||
subAgentCount: z.number().int().min(0).optional(),
|
||||
errorMessage: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Helper to update status
|
||||
function updateAgentStatus(update: Partial<AgentStatus>): AgentStatus {
|
||||
currentStatus = {
|
||||
...currentStatus,
|
||||
...update,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// GET /api/agent/status - Get current agent status
|
||||
router.get('/', asyncHandler(async (_req, res) => {
|
||||
res.json(currentStatus);
|
||||
}));
|
||||
|
||||
// POST /api/agent/status - Update agent status
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
const parsed = updateStatusSchema.safeParse(req.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError('Invalid status update', parsed.error.format());
|
||||
}
|
||||
|
||||
const update = parsed.data;
|
||||
|
||||
// Build the update object
|
||||
const newStatus: Partial<AgentStatus> = {};
|
||||
|
||||
if (update.status !== undefined) {
|
||||
newStatus.status = update.status;
|
||||
}
|
||||
|
||||
if (update.activeTask !== undefined) {
|
||||
newStatus.activeTask = update.activeTask ?? undefined;
|
||||
}
|
||||
|
||||
if (update.subAgentCount !== undefined) {
|
||||
newStatus.subAgentCount = update.subAgentCount;
|
||||
}
|
||||
|
||||
if (update.errorMessage !== undefined) {
|
||||
newStatus.errorMessage = update.errorMessage ?? undefined;
|
||||
}
|
||||
|
||||
// Clear activeTask and errorMessage when going idle
|
||||
if (update.status === 'idle') {
|
||||
newStatus.activeTask = undefined;
|
||||
newStatus.errorMessage = undefined;
|
||||
}
|
||||
|
||||
const result = updateAgentStatus(newStatus);
|
||||
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
app.use('/api/agent/status', router);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
|
||||
describe('GET /api/agent/status', () => {
|
||||
it('should return initial idle status', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/agent/status');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('idle');
|
||||
expect(res.body.subAgentCount).toBe(0);
|
||||
expect(res.body.lastUpdated).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return updated status after POST', async () => {
|
||||
// First update the status
|
||||
await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'working' });
|
||||
|
||||
// Then get it
|
||||
const res = await request(app)
|
||||
.get('/api/agent/status');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('working');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/agent/status', () => {
|
||||
it('should update status to working', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'working' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('working');
|
||||
expect(res.body.lastUpdated).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update status to thinking', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'thinking' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('thinking');
|
||||
});
|
||||
|
||||
it('should update status to sub-agent', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'sub-agent', subAgentCount: 2 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('sub-agent');
|
||||
expect(res.body.subAgentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should update status to error with message', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'error', errorMessage: 'Something went wrong' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('error');
|
||||
expect(res.body.errorMessage).toBe('Something went wrong');
|
||||
});
|
||||
|
||||
it('should set active task', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({
|
||||
status: 'working',
|
||||
activeTask: { id: 'task_123', title: 'Test Task' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('working');
|
||||
expect(res.body.activeTask).toEqual({ id: 'task_123', title: 'Test Task' });
|
||||
});
|
||||
|
||||
it('should clear activeTask and errorMessage when going idle', async () => {
|
||||
// First set working with active task
|
||||
await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({
|
||||
status: 'working',
|
||||
activeTask: { id: 'task_123' },
|
||||
errorMessage: 'Previous error',
|
||||
});
|
||||
|
||||
// Then go idle
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'idle' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('idle');
|
||||
expect(res.body.activeTask).toBeUndefined();
|
||||
expect(res.body.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear active task with null', async () => {
|
||||
// First set active task
|
||||
await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({
|
||||
status: 'working',
|
||||
activeTask: { id: 'task_123' },
|
||||
});
|
||||
|
||||
// Then clear it
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ activeTask: null });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.activeTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update subAgentCount', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ subAgentCount: 3 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subAgentCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should reject invalid status value', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'invalid-status' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject negative subAgentCount', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ subAgentCount: -1 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject non-integer subAgentCount', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ subAgentCount: 1.5 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject activeTask without id', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ activeTask: { title: 'No ID' } });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should allow partial updates', async () => {
|
||||
// Set initial state
|
||||
await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({
|
||||
status: 'working',
|
||||
subAgentCount: 2,
|
||||
});
|
||||
|
||||
// Update only subAgentCount
|
||||
const res = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ subAgentCount: 3 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('working'); // Preserved
|
||||
expect(res.body.subAgentCount).toBe(3); // Updated
|
||||
});
|
||||
|
||||
it('should update lastUpdated on each change', async () => {
|
||||
const res1 = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'working' });
|
||||
|
||||
const firstUpdate = res1.body.lastUpdated;
|
||||
|
||||
// Wait a small amount
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const res2 = await request(app)
|
||||
.post('/api/agent/status')
|
||||
.send({ status: 'thinking' });
|
||||
|
||||
expect(res2.body.lastUpdated).not.toBe(firstUpdate);
|
||||
});
|
||||
});
|
||||
});
|
||||
331
server/src/__tests__/routes/task-comments.test.ts
Normal file
331
server/src/__tests__/routes/task-comments.test.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
/**
|
||||
* Task Comments Route Integration Tests
|
||||
* Tests comment operations: add, edit, delete
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import express from 'express';
|
||||
import { TaskService } from '../../services/task-service.js';
|
||||
import { errorHandler, NotFoundError, ValidationError } from '../../middleware/error-handler.js';
|
||||
import { asyncHandler } from '../../middleware/async-handler.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Mock activity service to avoid file system side effects
|
||||
vi.mock('../../services/activity-service.js', () => ({
|
||||
activityService: {
|
||||
logActivity: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Task Comments Routes', () => {
|
||||
let app: express.Express;
|
||||
let testRoot: string;
|
||||
let tasksDir: string;
|
||||
let archiveDir: string;
|
||||
let taskService: TaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create fresh test directories
|
||||
const uniqueSuffix = Math.random().toString(36).substring(7);
|
||||
testRoot = path.join(os.tmpdir(), `veritas-test-comments-${uniqueSuffix}`);
|
||||
tasksDir = path.join(testRoot, 'active');
|
||||
archiveDir = path.join(testRoot, 'archive');
|
||||
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
await fs.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
// Create TaskService with test directories
|
||||
taskService = new TaskService({ tasksDir, archiveDir });
|
||||
|
||||
// Create app with test routes
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const addCommentSchema = z.object({
|
||||
author: z.string().min(1).max(100),
|
||||
text: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/comments - Add comment
|
||||
router.post('/:id/comments', asyncHandler(async (req, res) => {
|
||||
let author: string, text: string;
|
||||
try {
|
||||
({ author, text } = addCommentSchema.parse(req.body));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const task = await taskService.getTask(req.params.id as string);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
const comment = {
|
||||
id: `comment_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
author,
|
||||
text,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const comments = [...(task.comments || []), comment];
|
||||
const updatedTask = await taskService.updateTask(req.params.id as string, { comments });
|
||||
|
||||
res.status(201).json(updatedTask);
|
||||
}));
|
||||
|
||||
// PATCH /api/tasks/:id/comments/:commentId - Edit comment
|
||||
router.patch('/:id/comments/:commentId', asyncHandler(async (req, res) => {
|
||||
let text: string;
|
||||
try {
|
||||
({ text } = z.object({ text: z.string().min(1) }).parse(req.body));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const task = await taskService.getTask(req.params.id as string);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
const comments = task.comments || [];
|
||||
const commentIndex = comments.findIndex(c => c.id === req.params.commentId);
|
||||
if (commentIndex === -1) {
|
||||
throw new NotFoundError('Comment not found');
|
||||
}
|
||||
|
||||
comments[commentIndex] = {
|
||||
...comments[commentIndex],
|
||||
text,
|
||||
timestamp: comments[commentIndex].timestamp,
|
||||
};
|
||||
|
||||
const updatedTask = await taskService.updateTask(req.params.id as string, { comments });
|
||||
res.json(updatedTask);
|
||||
}));
|
||||
|
||||
// DELETE /api/tasks/:id/comments/:commentId - Delete comment
|
||||
router.delete('/:id/comments/:commentId', asyncHandler(async (req, res) => {
|
||||
const task = await taskService.getTask(req.params.id as string);
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
const comments = task.comments || [];
|
||||
const filtered = comments.filter(c => c.id !== req.params.commentId);
|
||||
if (filtered.length === comments.length) {
|
||||
throw new NotFoundError('Comment not found');
|
||||
}
|
||||
|
||||
const updatedTask = await taskService.updateTask(req.params.id as string, { comments: filtered });
|
||||
res.json(updatedTask);
|
||||
}));
|
||||
|
||||
app.use('/api/tasks', router);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testRoot) {
|
||||
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe('POST /api/tasks/:id/comments', () => {
|
||||
it('should add a comment to a task', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'TestUser', text: 'This is a test comment' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.comments).toHaveLength(1);
|
||||
expect(res.body.comments[0].author).toBe('TestUser');
|
||||
expect(res.body.comments[0].text).toBe('This is a test comment');
|
||||
expect(res.body.comments[0].id).toMatch(/^comment_/);
|
||||
expect(res.body.comments[0].timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should add multiple comments', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'User1', text: 'First comment' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'User2', text: 'Second comment' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.comments).toHaveLength(2);
|
||||
expect(res.body.comments[0].text).toBe('First comment');
|
||||
expect(res.body.comments[1].text).toBe('Second comment');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/nonexistent_id/comments')
|
||||
.send({ author: 'TestUser', text: 'Comment' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should reject empty author', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: '', text: 'Comment' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject empty text', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'TestUser', text: '' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject missing fields', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/tasks/:id/comments/:commentId', () => {
|
||||
it('should edit a comment', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
// Add a comment first
|
||||
const addRes = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'TestUser', text: 'Original text' });
|
||||
|
||||
const commentId = addRes.body.comments[0].id;
|
||||
|
||||
// Edit the comment
|
||||
const res = await request(app)
|
||||
.patch(`/api/tasks/${task.id}/comments/${commentId}`)
|
||||
.send({ text: 'Updated text' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.comments[0].text).toBe('Updated text');
|
||||
expect(res.body.comments[0].author).toBe('TestUser'); // Author preserved
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/nonexistent_id/comments/comment_123')
|
||||
.send({ text: 'Updated' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent comment', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/tasks/${task.id}/comments/nonexistent_comment`)
|
||||
.send({ text: 'Updated' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should reject empty text', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const addRes = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'TestUser', text: 'Original' });
|
||||
|
||||
const commentId = addRes.body.comments[0].id;
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/tasks/${task.id}/comments/${commentId}`)
|
||||
.send({ text: '' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tasks/:id/comments/:commentId', () => {
|
||||
it('should delete a comment', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
// Add a comment first
|
||||
const addRes = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'TestUser', text: 'To be deleted' });
|
||||
|
||||
const commentId = addRes.body.comments[0].id;
|
||||
|
||||
// Delete the comment
|
||||
const res = await request(app)
|
||||
.delete(`/api/tasks/${task.id}/comments/${commentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.comments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should delete only the specified comment', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
// Add two comments
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'User1', text: 'First' });
|
||||
|
||||
const addRes = await request(app)
|
||||
.post(`/api/tasks/${task.id}/comments`)
|
||||
.send({ author: 'User2', text: 'Second' });
|
||||
|
||||
const secondCommentId = addRes.body.comments[1].id;
|
||||
|
||||
// Delete the second comment
|
||||
const res = await request(app)
|
||||
.delete(`/api/tasks/${task.id}/comments/${secondCommentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.comments).toHaveLength(1);
|
||||
expect(res.body.comments[0].text).toBe('First');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/api/tasks/nonexistent_id/comments/comment_123');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent comment', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tasks/${task.id}/comments/nonexistent_comment`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
379
server/src/__tests__/routes/task-time.test.ts
Normal file
379
server/src/__tests__/routes/task-time.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
/**
|
||||
* Task Time Tracking Route Integration Tests
|
||||
* Tests time tracking: start, stop, entry, summary
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import express from 'express';
|
||||
import { TaskService } from '../../services/task-service.js';
|
||||
import { errorHandler, ValidationError, NotFoundError } from '../../middleware/error-handler.js';
|
||||
import { asyncHandler } from '../../middleware/async-handler.js';
|
||||
|
||||
describe('Task Time Routes', () => {
|
||||
let app: express.Express;
|
||||
let testRoot: string;
|
||||
let tasksDir: string;
|
||||
let archiveDir: string;
|
||||
let taskService: TaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create fresh test directories
|
||||
const uniqueSuffix = Math.random().toString(36).substring(7);
|
||||
testRoot = path.join(os.tmpdir(), `veritas-test-time-${uniqueSuffix}`);
|
||||
tasksDir = path.join(testRoot, 'active');
|
||||
archiveDir = path.join(testRoot, 'archive');
|
||||
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
await fs.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
// Create TaskService with test directories
|
||||
taskService = new TaskService({ tasksDir, archiveDir });
|
||||
|
||||
// Create app with test routes
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/tasks/time/summary - Get time summary by project
|
||||
router.get('/time/summary', asyncHandler(async (_req, res) => {
|
||||
const summary = await taskService.getTimeSummary();
|
||||
res.json(summary);
|
||||
}));
|
||||
|
||||
// POST /api/tasks/:id/time/start - Start timer for a task
|
||||
router.post('/:id/time/start', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const task = await taskService.startTimer(req.params.id as string);
|
||||
res.json(task);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Task not found') {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
if (error.message === 'Timer is already running for this task') {
|
||||
throw new ValidationError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
// POST /api/tasks/:id/time/stop - Stop timer for a task
|
||||
router.post('/:id/time/stop', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const task = await taskService.stopTimer(req.params.id as string);
|
||||
res.json(task);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Task not found') {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
if (error.message === 'No timer is running for this task') {
|
||||
throw new ValidationError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
// POST /api/tasks/:id/time/entry - Add manual time entry
|
||||
router.post('/:id/time/entry', asyncHandler(async (req, res) => {
|
||||
const { duration, description } = req.body;
|
||||
if (typeof duration !== 'number' || duration <= 0) {
|
||||
throw new ValidationError('Duration must be a positive number (in seconds)');
|
||||
}
|
||||
try {
|
||||
const task = await taskService.addTimeEntry(req.params.id as string, duration, description);
|
||||
res.json(task);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Task not found') {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
// DELETE /api/tasks/:id/time/entry/:entryId - Delete a time entry
|
||||
router.delete('/:id/time/entry/:entryId', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const task = await taskService.deleteTimeEntry(req.params.id as string, req.params.entryId as string);
|
||||
res.json(task);
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Task not found') {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
if (error.message === 'Time entry not found') {
|
||||
throw new NotFoundError('Time entry not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.use('/api/tasks', router);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testRoot) {
|
||||
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe('POST /api/tasks/:id/time/start', () => {
|
||||
it('should start a timer for a task', async () => {
|
||||
const task = await taskService.createTask({ title: 'Timed Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/start`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.timeTracking).toBeDefined();
|
||||
expect(res.body.timeTracking.isRunning).toBe(true);
|
||||
expect(res.body.timeTracking.activeEntryId).toBeDefined();
|
||||
expect(res.body.timeTracking.entries).toHaveLength(1);
|
||||
expect(res.body.timeTracking.entries[0].startTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/nonexistent_id/time/start');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should not allow starting already running timer', async () => {
|
||||
const task = await taskService.createTask({ title: 'Running Task' });
|
||||
|
||||
// Start the timer
|
||||
await request(app).post(`/api/tasks/${task.id}/time/start`);
|
||||
|
||||
// Try to start again
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/start`);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks/:id/time/stop', () => {
|
||||
it('should stop a running timer', async () => {
|
||||
const task = await taskService.createTask({ title: 'Timed Task' });
|
||||
|
||||
// Start the timer
|
||||
await request(app).post(`/api/tasks/${task.id}/time/start`);
|
||||
|
||||
// Wait a tiny bit to ensure some time passes
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Stop the timer
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/stop`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.timeTracking.isRunning).toBe(false);
|
||||
expect(res.body.timeTracking.activeEntryId).toBeUndefined();
|
||||
expect(res.body.timeTracking.entries[0].endTime).toBeDefined();
|
||||
expect(res.body.timeTracking.entries[0].duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/nonexistent_id/time/stop');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should fail when no timer is running', async () => {
|
||||
const task = await taskService.createTask({ title: 'No Timer Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/stop`);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks/:id/time/entry', () => {
|
||||
it('should add a manual time entry', async () => {
|
||||
const task = await taskService.createTask({ title: 'Manual Entry Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 3600, description: 'Worked on feature' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.timeTracking.entries).toHaveLength(1);
|
||||
expect(res.body.timeTracking.entries[0].duration).toBe(3600);
|
||||
expect(res.body.timeTracking.entries[0].description).toBe('Worked on feature');
|
||||
expect(res.body.timeTracking.entries[0].manual).toBe(true);
|
||||
expect(res.body.timeTracking.totalSeconds).toBe(3600);
|
||||
});
|
||||
|
||||
it('should add entry without description', async () => {
|
||||
const task = await taskService.createTask({ title: 'Manual Entry Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 1800 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.timeTracking.entries[0].duration).toBe(1800);
|
||||
});
|
||||
|
||||
it('should reject negative duration', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: -100 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject zero duration', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 0 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject non-numeric duration', async () => {
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 'not-a-number' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/nonexistent_id/time/entry')
|
||||
.send({ duration: 3600 });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should accumulate total time with multiple entries', async () => {
|
||||
const task = await taskService.createTask({ title: 'Multi-entry Task' });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 1800 });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 1200 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.timeTracking.entries).toHaveLength(2);
|
||||
expect(res.body.timeTracking.totalSeconds).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tasks/:id/time/entry/:entryId', () => {
|
||||
it('should delete a time entry', async () => {
|
||||
const task = await taskService.createTask({ title: 'Delete Entry Task' });
|
||||
|
||||
// Add an entry
|
||||
const addRes = await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 3600 });
|
||||
|
||||
const entryId = addRes.body.timeTracking.entries[0].id;
|
||||
|
||||
// Delete it
|
||||
const res = await request(app)
|
||||
.delete(`/api/tasks/${task.id}/time/entry/${entryId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.timeTracking.entries).toHaveLength(0);
|
||||
expect(res.body.timeTracking.totalSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/api/tasks/nonexistent_id/time/entry/entry_123');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should succeed silently for non-existent entry (no error thrown)', async () => {
|
||||
// Note: The TaskService doesn't throw for non-existent entries, it just filters
|
||||
const task = await taskService.createTask({ title: 'Test Task' });
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/tasks/${task.id}/time/entry/nonexistent_entry`);
|
||||
|
||||
// TaskService doesn't throw for non-existent entries, just returns task as-is
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tasks/time/summary', () => {
|
||||
it('should return empty summary when no tasks', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/tasks/time/summary');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.byProject).toEqual([]);
|
||||
expect(res.body.total).toBe(0);
|
||||
});
|
||||
|
||||
it('should return summary grouped by project', async () => {
|
||||
// Create tasks in different projects with time
|
||||
const task1 = await taskService.createTask({ title: 'Task 1', project: 'project-a' });
|
||||
const task2 = await taskService.createTask({ title: 'Task 2', project: 'project-a' });
|
||||
const task3 = await taskService.createTask({ title: 'Task 3', project: 'project-b' });
|
||||
|
||||
// Add time to each
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task1.id}/time/entry`)
|
||||
.send({ duration: 3600 });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task2.id}/time/entry`)
|
||||
.send({ duration: 1800 });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task3.id}/time/entry`)
|
||||
.send({ duration: 7200 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tasks/time/summary');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.total).toBe(12600);
|
||||
expect(res.body.byProject).toHaveLength(2);
|
||||
|
||||
// byProject is sorted by totalSeconds descending
|
||||
const projectB = res.body.byProject.find((p: any) => p.project === 'project-b');
|
||||
const projectA = res.body.byProject.find((p: any) => p.project === 'project-a');
|
||||
|
||||
expect(projectA.totalSeconds).toBe(5400);
|
||||
expect(projectA.taskCount).toBe(2);
|
||||
expect(projectB.totalSeconds).toBe(7200);
|
||||
expect(projectB.taskCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle tasks without project', async () => {
|
||||
const task = await taskService.createTask({ title: 'No Project Task' });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/tasks/${task.id}/time/entry`)
|
||||
.send({ duration: 1800 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/tasks/time/summary');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.byProject).toHaveLength(1);
|
||||
expect(res.body.byProject[0].project).toBe('(No Project)');
|
||||
expect(res.body.byProject[0].totalSeconds).toBe(1800);
|
||||
});
|
||||
});
|
||||
});
|
||||
281
server/src/__tests__/routes/tasks.test.ts
Normal file
281
server/src/__tests__/routes/tasks.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* Tasks Route Integration Tests
|
||||
* Tests CRUD operations for /api/tasks endpoints
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import express from 'express';
|
||||
import { TaskService } from '../../services/task-service.js';
|
||||
import { taskRoutes } from '../../routes/tasks.js';
|
||||
import { errorHandler } from '../../middleware/error-handler.js';
|
||||
|
||||
describe('Tasks Routes', () => {
|
||||
let app: express.Express;
|
||||
let testRoot: string;
|
||||
let tasksDir: string;
|
||||
let archiveDir: string;
|
||||
let taskService: TaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create fresh test directories
|
||||
const uniqueSuffix = Math.random().toString(36).substring(7);
|
||||
testRoot = path.join(os.tmpdir(), `veritas-test-routes-${uniqueSuffix}`);
|
||||
tasksDir = path.join(testRoot, 'active');
|
||||
archiveDir = path.join(testRoot, 'archive');
|
||||
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
await fs.mkdir(archiveDir, { recursive: true });
|
||||
|
||||
// Create TaskService with test directories
|
||||
taskService = new TaskService({ tasksDir, archiveDir });
|
||||
|
||||
// Create app with mocked routes
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Create a router that uses our test TaskService
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/tasks - List all tasks
|
||||
router.get('/', async (_req, res) => {
|
||||
const tasks = await taskService.listTasks();
|
||||
res.json(tasks);
|
||||
});
|
||||
|
||||
// POST /api/tasks - Create task
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const task = await taskService.createTask(req.body);
|
||||
res.status(201).json(task);
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id - Get single task
|
||||
router.get('/:id', async (req, res) => {
|
||||
const task = await taskService.getTask(req.params.id);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.json(task);
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/:id - Update task
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const task = await taskService.updateTask(req.params.id, req.body);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.json(task);
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id - Delete task
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const success = await taskService.deleteTask(req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
// POST /api/tasks/reorder - Reorder tasks
|
||||
router.post('/reorder', async (req, res) => {
|
||||
const { orderedIds } = req.body;
|
||||
if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
|
||||
return res.status(400).json({ error: 'orderedIds must be a non-empty array' });
|
||||
}
|
||||
const updated = await taskService.reorderTasks(orderedIds);
|
||||
res.json({ updated: updated.length });
|
||||
});
|
||||
|
||||
app.use('/api/tasks', router);
|
||||
app.use(errorHandler);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testRoot) {
|
||||
await fs.rm(testRoot, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/tasks', () => {
|
||||
it('should return empty array when no tasks exist', async () => {
|
||||
const res = await request(app).get('/api/tasks');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all tasks', async () => {
|
||||
await taskService.createTask({ title: 'Task 1' });
|
||||
await taskService.createTask({ title: 'Task 2' });
|
||||
|
||||
const res = await request(app).get('/api/tasks');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body.map((t: any) => t.title)).toContain('Task 1');
|
||||
expect(res.body.map((t: any) => t.title)).toContain('Task 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks', () => {
|
||||
it('should create a task with minimal fields', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send({ title: 'New Task' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe('New Task');
|
||||
expect(res.body.id).toMatch(/^task_/);
|
||||
expect(res.body.status).toBe('todo');
|
||||
expect(res.body.priority).toBe('medium');
|
||||
expect(res.body.type).toBe('code');
|
||||
});
|
||||
|
||||
it('should create a task with all fields', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks')
|
||||
.send({
|
||||
title: 'Full Task',
|
||||
description: 'Detailed description',
|
||||
type: 'research',
|
||||
priority: 'high',
|
||||
project: 'test-project',
|
||||
sprint: 'US-100',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.title).toBe('Full Task');
|
||||
expect(res.body.description).toBe('Detailed description');
|
||||
expect(res.body.type).toBe('research');
|
||||
expect(res.body.priority).toBe('high');
|
||||
expect(res.body.project).toBe('test-project');
|
||||
expect(res.body.sprint).toBe('US-100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/tasks/:id', () => {
|
||||
it('should return a task by id', async () => {
|
||||
const task = await taskService.createTask({ title: 'Fetch Me' });
|
||||
|
||||
const res = await request(app).get(`/api/tasks/${task.id}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(task.id);
|
||||
expect(res.body.title).toBe('Fetch Me');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app).get('/api/tasks/nonexistent_id');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/tasks/:id', () => {
|
||||
it('should update task fields', async () => {
|
||||
const task = await taskService.createTask({ title: 'Original' });
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/tasks/${task.id}`)
|
||||
.send({
|
||||
title: 'Updated',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Updated');
|
||||
expect(res.body.status).toBe('in-progress');
|
||||
expect(res.body.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/api/tasks/nonexistent_id')
|
||||
.send({ title: 'Will Fail' });
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should update only provided fields', async () => {
|
||||
const task = await taskService.createTask({
|
||||
title: 'Original Title',
|
||||
description: 'Original Desc',
|
||||
priority: 'low',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/api/tasks/${task.id}`)
|
||||
.send({ priority: 'high' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.title).toBe('Original Title');
|
||||
expect(res.body.description).toBe('Original Desc');
|
||||
expect(res.body.priority).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tasks/:id', () => {
|
||||
it('should delete a task', async () => {
|
||||
const task = await taskService.createTask({ title: 'Delete Me' });
|
||||
|
||||
const res = await request(app).delete(`/api/tasks/${task.id}`);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
// Verify task is gone
|
||||
const tasks = await taskService.listTasks();
|
||||
expect(tasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent task', async () => {
|
||||
const res = await request(app).delete('/api/tasks/nonexistent_id');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tasks/reorder', () => {
|
||||
it('should reorder tasks', async () => {
|
||||
const task1 = await taskService.createTask({ title: 'Task 1' });
|
||||
const task2 = await taskService.createTask({ title: 'Task 2' });
|
||||
const task3 = await taskService.createTask({ title: 'Task 3' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/reorder')
|
||||
.send({ orderedIds: [task3.id, task1.id, task2.id] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.updated).toBe(3);
|
||||
|
||||
// Verify positions
|
||||
const tasks = await taskService.listTasks();
|
||||
const taskMap = new Map(tasks.map(t => [t.id, t]));
|
||||
expect(taskMap.get(task3.id)?.position).toBe(0);
|
||||
expect(taskMap.get(task1.id)?.position).toBe(1);
|
||||
expect(taskMap.get(task2.id)?.position).toBe(2);
|
||||
});
|
||||
|
||||
it('should reject empty orderedIds', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/reorder')
|
||||
.send({ orderedIds: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject missing orderedIds', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/tasks/reorder')
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { getTelemetryService } from '../services/telemetry-service.js';
|
||||
import { broadcastTelemetryEvent } from '../services/broadcast-service.js';
|
||||
import { getFailureAlertService } from '../services/failure-alert-service.js';
|
||||
import { getTaskService } from '../services/task-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { validate, type ValidatedRequest } from '../middleware/validate.js';
|
||||
import type { TelemetryQueryOptions, AnyTelemetryEvent } from '@veritas-kanban/shared';
|
||||
|
|
@ -46,6 +48,29 @@ router.post(
|
|||
// Broadcast to WebSocket clients
|
||||
broadcastTelemetryEvent(event as AnyTelemetryEvent);
|
||||
|
||||
// Check for failure events and send alerts (non-blocking)
|
||||
const failureAlertService = getFailureAlertService();
|
||||
if (failureAlertService.isFailureEvent(eventInput)) {
|
||||
// Look up task title asynchronously (don't block response)
|
||||
(async () => {
|
||||
try {
|
||||
let taskTitle: string | undefined;
|
||||
try {
|
||||
const taskService = getTaskService();
|
||||
const task = await taskService.getTask(eventInput.taskId);
|
||||
taskTitle = task?.title;
|
||||
} catch {
|
||||
// Task not found is fine, we'll use taskId
|
||||
}
|
||||
|
||||
await failureAlertService.processEvent(eventInput, taskTitle);
|
||||
} catch (err) {
|
||||
// Graceful failure: log but don't crash
|
||||
console.error('[Telemetry] Failure alert error:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
res.status(201).json(event);
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -274,3 +274,13 @@ export class ConfigService {
|
|||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let configInstance: ConfigService | null = null;
|
||||
|
||||
export function getConfigService(): ConfigService {
|
||||
if (!configInstance) {
|
||||
configInstance = new ConfigService();
|
||||
}
|
||||
return configInstance;
|
||||
}
|
||||
|
|
|
|||
266
server/src/services/failure-alert-service.ts
Normal file
266
server/src/services/failure-alert-service.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Failure Alert Service
|
||||
*
|
||||
* Automatically notifies Teams when agent runs fail.
|
||||
* Detects run.error and run.completed with success:false telemetry events.
|
||||
*
|
||||
* Features:
|
||||
* - Configurable on/off (default off)
|
||||
* - Deduplication: won't spam for same task retries within 5 min
|
||||
* - Graceful failure: logs but doesn't crash if notification fails
|
||||
* - Supports immediate delivery via Teams webhook (if configured)
|
||||
*/
|
||||
|
||||
import { getNotificationService, type NotificationService } from './notification-service.js';
|
||||
import { getConfigService, type ConfigService } from './config-service.js';
|
||||
import type { TelemetryEventIngestion } from '../schemas/telemetry-schemas.js';
|
||||
|
||||
// Deduplication cache: taskId -> last alert timestamp
|
||||
const recentAlerts = new Map<string, number>();
|
||||
const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Teams channel for failure alerts (Tasks channel from TOOLS.md)
|
||||
const DEFAULT_TASKS_CHANNEL = '19:abdf236ec5424dbcb57676ba630c8434@thread.tacv2';
|
||||
|
||||
export interface FailureAlertResult {
|
||||
sent: boolean;
|
||||
reason?: 'disabled' | 'not-failure' | 'deduplicated' | 'error';
|
||||
notificationId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FailureAlertServiceOptions {
|
||||
notificationService?: NotificationService;
|
||||
configService?: ConfigService;
|
||||
dedupWindowMs?: number;
|
||||
}
|
||||
|
||||
export class FailureAlertService {
|
||||
private notifications: NotificationService;
|
||||
private configService: ConfigService;
|
||||
private dedupWindowMs: number;
|
||||
|
||||
constructor(options: FailureAlertServiceOptions = {}) {
|
||||
this.notifications = options.notificationService || getNotificationService();
|
||||
this.configService = options.configService || getConfigService();
|
||||
this.dedupWindowMs = options.dedupWindowMs || DEDUP_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is a failure that should trigger an alert
|
||||
*/
|
||||
isFailureEvent(event: TelemetryEventIngestion): boolean {
|
||||
if (event.type === 'run.error') return true;
|
||||
if (event.type === 'run.completed' && !event.success) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we've recently alerted for this task (deduplication)
|
||||
*/
|
||||
isRecentlyAlerted(taskId: string): boolean {
|
||||
const lastAlert = recentAlerts.get(taskId);
|
||||
if (!lastAlert) return false;
|
||||
|
||||
const elapsed = Date.now() - lastAlert;
|
||||
return elapsed < this.dedupWindowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that we've sent an alert for this task
|
||||
*/
|
||||
private recordAlert(taskId: string): void {
|
||||
recentAlerts.set(taskId, Date.now());
|
||||
|
||||
// Clean up old entries periodically
|
||||
if (recentAlerts.size > 100) {
|
||||
const cutoff = Date.now() - this.dedupWindowMs;
|
||||
for (const [id, timestamp] of recentAlerts.entries()) {
|
||||
if (timestamp < cutoff) {
|
||||
recentAlerts.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from event
|
||||
*/
|
||||
private getErrorMessage(event: TelemetryEventIngestion): string {
|
||||
if (event.type === 'run.error') {
|
||||
return event.error;
|
||||
}
|
||||
if (event.type === 'run.completed' && event.error) {
|
||||
return event.error;
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent name from event
|
||||
*/
|
||||
private getAgentName(event: TelemetryEventIngestion): string {
|
||||
if ('agent' in event) {
|
||||
return event.agent;
|
||||
}
|
||||
return 'Unknown agent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a telemetry event and send failure alert if appropriate
|
||||
*/
|
||||
async processEvent(
|
||||
event: TelemetryEventIngestion,
|
||||
taskTitle?: string
|
||||
): Promise<FailureAlertResult> {
|
||||
try {
|
||||
// Check if it's a failure event
|
||||
if (!this.isFailureEvent(event)) {
|
||||
return { sent: false, reason: 'not-failure' };
|
||||
}
|
||||
|
||||
// Check if notifications are enabled
|
||||
const features = await this.configService.getFeatureSettings();
|
||||
const notifSettings = features.notifications;
|
||||
|
||||
if (!notifSettings.enabled || !notifSettings.onAgentFailure) {
|
||||
return { sent: false, reason: 'disabled' };
|
||||
}
|
||||
|
||||
// Check deduplication
|
||||
if (this.isRecentlyAlerted(event.taskId)) {
|
||||
console.log(`[FailureAlert] Skipping duplicate alert for task ${event.taskId}`);
|
||||
return { sent: false, reason: 'deduplicated' };
|
||||
}
|
||||
|
||||
// Create notification
|
||||
const agent = this.getAgentName(event);
|
||||
const error = this.getErrorMessage(event);
|
||||
const title = taskTitle || event.taskId;
|
||||
|
||||
const notification = await this.notifications.createNotification({
|
||||
type: 'agent_failed',
|
||||
title: 'Agent Run Failed',
|
||||
message: `**${agent}** failed on "${title}"\n\n**Error:** ${this.truncateError(error)}`,
|
||||
taskId: event.taskId,
|
||||
taskTitle: taskTitle,
|
||||
project: event.project,
|
||||
});
|
||||
|
||||
// Record for deduplication
|
||||
this.recordAlert(event.taskId);
|
||||
|
||||
// Try to send via webhook for immediate delivery (non-blocking)
|
||||
const formattedMessage = this.formatFailureMessage(
|
||||
agent,
|
||||
title,
|
||||
event.taskId,
|
||||
error,
|
||||
event.project
|
||||
);
|
||||
this.sendToWebhook(formattedMessage).catch(() => {
|
||||
// Ignore webhook errors - notification is still stored for polling
|
||||
});
|
||||
|
||||
// Log success
|
||||
console.log(`[FailureAlert] Created notification ${notification.id} for task ${event.taskId}`);
|
||||
|
||||
return { sent: true, notificationId: notification.id };
|
||||
} catch (err) {
|
||||
// Graceful failure: log but don't crash
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[FailureAlert] Failed to send alert: ${errorMsg}`);
|
||||
return { sent: false, reason: 'error', error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate long error messages for notification
|
||||
*/
|
||||
private truncateError(error: string, maxLength = 200): string {
|
||||
if (error.length <= maxLength) return error;
|
||||
return error.slice(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured Teams channel for alerts
|
||||
*/
|
||||
async getAlertChannel(): Promise<string> {
|
||||
const features = await this.configService.getFeatureSettings();
|
||||
return features.notifications.channel || DEFAULT_TASKS_CHANNEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send immediate notification via Teams webhook (if configured)
|
||||
* Falls back silently if webhook not configured or delivery fails
|
||||
*/
|
||||
async sendToWebhook(message: string): Promise<boolean> {
|
||||
try {
|
||||
const features = await this.configService.getFeatureSettings();
|
||||
const webhookUrl = features.notifications.webhookUrl;
|
||||
|
||||
if (!webhookUrl) {
|
||||
// No webhook configured - notification will be available via polling
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`[FailureAlert] Webhook delivery failed: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[FailureAlert] Webhook delivery successful');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[FailureAlert] Webhook error:', err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a failure message for Teams
|
||||
*/
|
||||
formatFailureMessage(
|
||||
agent: string,
|
||||
taskTitle: string,
|
||||
taskId: string,
|
||||
error: string,
|
||||
project?: string
|
||||
): string {
|
||||
let message = `❌ **Agent Run Failed**\n\n`;
|
||||
message += `**Agent:** ${agent}\n`;
|
||||
message += `**Task:** ${taskTitle}\n`;
|
||||
if (project) message += `**Project:** ${project}\n`;
|
||||
message += `**Error:** ${this.truncateError(error)}\n\n`;
|
||||
message += `🔗 \`vk show ${taskId.slice(-8)}\``;
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear deduplication cache (for testing)
|
||||
*/
|
||||
clearDedupCache(): void {
|
||||
recentAlerts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let instance: FailureAlertService | null = null;
|
||||
|
||||
export function getFailureAlertService(): FailureAlertService {
|
||||
if (!instance) {
|
||||
instance = new FailureAlertService();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Re-export ConfigService getter for completeness
|
||||
export { getConfigService } from './config-service.js';
|
||||
|
|
@ -647,3 +647,13 @@ export class TaskService {
|
|||
return { byProject, total };
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let taskServiceInstance: TaskService | null = null;
|
||||
|
||||
export function getTaskService(): TaskService {
|
||||
if (!taskServiceInstance) {
|
||||
taskServiceInstance = new TaskService();
|
||||
}
|
||||
return taskServiceInstance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export interface NotificationSettings {
|
|||
onAgentFailure: boolean;
|
||||
onReviewNeeded: boolean;
|
||||
channel: string; // Teams channel ID
|
||||
webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery
|
||||
}
|
||||
|
||||
/** Archive settings */
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ import { ReviewPanel } from './ReviewPanel';
|
|||
import { PreviewPanel } from './PreviewPanel';
|
||||
import { AttachmentsSection } from './AttachmentsSection';
|
||||
import { ApplyTemplateDialog } from './ApplyTemplateDialog';
|
||||
import { TaskMetricsPanel } from './TaskMetricsPanel';
|
||||
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
|
||||
import { GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode, Paperclip, Archive } from 'lucide-react';
|
||||
import { GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode, Paperclip, Archive, BarChart3 } from 'lucide-react';
|
||||
import type { Task, ReviewComment, ReviewState } from '@veritas-kanban/shared';
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
|
|
@ -100,7 +101,7 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on
|
|||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TabsList className={`grid flex-1 ${isCodeTask ? (taskSettings.enableAttachments ? 'grid-cols-6' : 'grid-cols-5') : (taskSettings.enableAttachments ? 'grid-cols-2' : 'grid-cols-1')}`}>
|
||||
<TabsList className={`grid flex-1 ${isCodeTask ? (taskSettings.enableAttachments ? 'grid-cols-7' : 'grid-cols-6') : (taskSettings.enableAttachments ? 'grid-cols-3' : 'grid-cols-2')}`}>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
{taskSettings.enableAttachments && (
|
||||
<TabsTrigger value="attachments" className="flex items-center gap-1">
|
||||
|
|
@ -128,6 +129,10 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on
|
|||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger value="metrics" className="flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
Metrics
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
|
|
@ -228,6 +233,13 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on
|
|||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Metrics Tab */}
|
||||
<TabsContent value="metrics" className="mt-0">
|
||||
<FeatureErrorBoundary fallbackTitle="Metrics panel failed to load">
|
||||
<TaskMetricsPanel task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
|
|
|
|||
357
web/src/components/task/TaskMetricsPanel.tsx
Normal file
357
web/src/components/task/TaskMetricsPanel.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { useState } from 'react';
|
||||
import { useTaskMetrics, type AttemptMetrics } from '@/hooks/useTaskMetrics';
|
||||
import { formatDuration, formatTokens } from '@/hooks/useMetrics';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Play,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Coins,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Bot,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
|
||||
interface TaskMetricsPanelProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
variant = 'default',
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
subValue?: string;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error';
|
||||
}) {
|
||||
const variantClasses = {
|
||||
default: 'bg-muted/50',
|
||||
success: 'bg-green-500/10 border-green-500/20',
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/20',
|
||||
error: 'bg-red-500/10 border-red-500/20',
|
||||
};
|
||||
|
||||
const iconClasses = {
|
||||
default: 'text-muted-foreground',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-yellow-500',
|
||||
error: 'text-red-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${variantClasses[variant]}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className={`h-4 w-4 ${iconClasses[variant]}`} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-semibold">{value}</div>
|
||||
{subValue && <div className="text-xs text-muted-foreground">{subValue}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttemptRow({ attempt, isExpanded, onToggle }: {
|
||||
attempt: AttemptMetrics;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCost = (cost: number) => {
|
||||
if (cost === 0) return '-';
|
||||
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{attempt.success === true && (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
)}
|
||||
{attempt.success === false && (
|
||||
<XCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
|
||||
)}
|
||||
{attempt.success === undefined && (
|
||||
<Play className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{attempt.agent}</span>
|
||||
{attempt.model && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{attempt.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(attempt.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{attempt.durationMs !== undefined && (
|
||||
<span>{formatDuration(attempt.durationMs)}</span>
|
||||
)}
|
||||
{attempt.totalTokens > 0 && (
|
||||
<span>{formatTokens(attempt.totalTokens)} tokens</span>
|
||||
)}
|
||||
{attempt.cost > 0 && (
|
||||
<span>{formatCost(attempt.cost)}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/30 p-3 space-y-3">
|
||||
{/* Token breakdown */}
|
||||
{attempt.totalTokens > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Input:</span>{' '}
|
||||
<span className="font-mono">{formatTokens(attempt.inputTokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Output:</span>{' '}
|
||||
<span className="font-mono">{formatTokens(attempt.outputTokens)}</span>
|
||||
</div>
|
||||
{attempt.cacheTokens > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache:</span>{' '}
|
||||
<span className="font-mono">{formatTokens(attempt.cacheTokens)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration details */}
|
||||
{attempt.durationMs !== undefined && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Duration:</span>{' '}
|
||||
<span className="font-mono">{formatDuration(attempt.durationMs)}</span>
|
||||
<span className="text-muted-foreground ml-2">({attempt.durationMs.toLocaleString()}ms)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{attempt.error && (
|
||||
<div className="flex items-start gap-2 p-2 bg-red-500/10 border border-red-500/20 rounded text-sm">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-red-400">{attempt.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost */}
|
||||
{attempt.cost > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Estimated Cost:</span>{' '}
|
||||
<span className="font-mono">{formatCost(attempt.cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskMetricsPanel({ task }: TaskMetricsPanelProps) {
|
||||
const { data: metrics, isLoading, error } = useTaskMetrics(task.id);
|
||||
const [expandedAttempts, setExpandedAttempts] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleAttempt = (attemptId: string) => {
|
||||
setExpandedAttempts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(attemptId)) {
|
||||
next.delete(attemptId);
|
||||
} else {
|
||||
next.add(attemptId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const formatCost = (cost: number) => {
|
||||
if (cost === 0) return '$0';
|
||||
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
<span className="text-red-400">Failed to load metrics</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!metrics || metrics.totalRuns === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center space-y-2">
|
||||
<Bot className="h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="font-medium">No Run Data</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This task hasn't been run by an agent yet. Run metrics will appear here after agent execution.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const successRateVariant =
|
||||
metrics.successRate >= 0.8 ? 'success' :
|
||||
metrics.successRate >= 0.5 ? 'warning' : 'error';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<MetricCard
|
||||
icon={Play}
|
||||
label="Total Runs"
|
||||
value={metrics.totalRuns}
|
||||
subValue={`${metrics.successfulRuns} successful, ${metrics.failedRuns} failed`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={TrendingUp}
|
||||
label="Success Rate"
|
||||
value={`${(metrics.successRate * 100).toFixed(0)}%`}
|
||||
variant={successRateVariant}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Clock}
|
||||
label="Total Duration"
|
||||
value={formatDuration(metrics.totalDurationMs)}
|
||||
subValue={`Avg: ${formatDuration(metrics.avgDurationMs)}`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Zap}
|
||||
label="Input Tokens"
|
||||
value={formatTokens(metrics.totalInputTokens)}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Zap}
|
||||
label="Output Tokens"
|
||||
value={formatTokens(metrics.totalOutputTokens)}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Coins}
|
||||
label="Estimated Cost"
|
||||
value={formatCost(metrics.totalCost)}
|
||||
subValue={metrics.totalCacheTokens > 0 ? `${formatTokens(metrics.totalCacheTokens)} cached` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Run Summary */}
|
||||
{metrics.lastRun && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Last Run
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
{metrics.lastRun.success === true && (
|
||||
<Badge variant="default" className="bg-green-500/20 text-green-500 border-green-500/30">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Success
|
||||
</Badge>
|
||||
)}
|
||||
{metrics.lastRun.success === false && (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Failed
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm">{metrics.lastRun.agent}</span>
|
||||
{metrics.lastRun.model && (
|
||||
<Badge variant="secondary">{metrics.lastRun.model}</Badge>
|
||||
)}
|
||||
{metrics.lastRun.durationMs && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDuration(metrics.lastRun.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attempt History */}
|
||||
{metrics.attempts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Run History
|
||||
</h3>
|
||||
{metrics.attempts.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (expandedAttempts.size === metrics.attempts.length) {
|
||||
setExpandedAttempts(new Set());
|
||||
} else {
|
||||
setExpandedAttempts(new Set(metrics.attempts.map(a => a.attemptId)));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{expandedAttempts.size === metrics.attempts.length ? 'Collapse All' : 'Expand All'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{metrics.attempts.map(attempt => (
|
||||
<AttemptRow
|
||||
key={attempt.attemptId}
|
||||
attempt={attempt}
|
||||
isExpanded={expandedAttempts.has(attempt.attemptId)}
|
||||
onToggle={() => toggleAttempt(attempt.attemptId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
web/src/hooks/useTaskMetrics.ts
Normal file
192
web/src/hooks/useTaskMetrics.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import type {
|
||||
AnyTelemetryEvent,
|
||||
RunStartedEvent,
|
||||
RunCompletedEvent,
|
||||
RunErrorEvent,
|
||||
TokenTelemetryEvent,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface AttemptMetrics {
|
||||
attemptId: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
agent: string;
|
||||
model?: string;
|
||||
durationMs?: number;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface AggregatedTaskMetrics {
|
||||
taskId: string;
|
||||
totalRuns: number;
|
||||
successfulRuns: number;
|
||||
failedRuns: number;
|
||||
successRate: number;
|
||||
totalDurationMs: number;
|
||||
avgDurationMs: number;
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalCacheTokens: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
lastRun?: AttemptMetrics;
|
||||
attempts: AttemptMetrics[];
|
||||
}
|
||||
|
||||
async function fetchTaskTelemetry(taskId: string): Promise<AnyTelemetryEvent[]> {
|
||||
const response = await fetch(`${API_BASE}/telemetry/events/task/${encodeURIComponent(taskId)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch task telemetry');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function aggregateMetrics(taskId: string, events: AnyTelemetryEvent[]): AggregatedTaskMetrics {
|
||||
// Group events by attempt/session
|
||||
const attemptMap = new Map<string, {
|
||||
started?: RunStartedEvent;
|
||||
completed?: RunCompletedEvent;
|
||||
error?: RunErrorEvent;
|
||||
tokens?: TokenTelemetryEvent;
|
||||
}>();
|
||||
|
||||
// Process events
|
||||
for (const event of events) {
|
||||
if (!event.taskId || event.taskId !== taskId) continue;
|
||||
|
||||
// Use attemptId if available, or generate one from timestamp+agent
|
||||
let attemptKey: string | undefined;
|
||||
|
||||
if (event.type === 'run.started') {
|
||||
const e = event as RunStartedEvent;
|
||||
attemptKey = e.attemptId || `${e.timestamp}_${e.agent}`;
|
||||
const attempt = attemptMap.get(attemptKey) || {};
|
||||
attempt.started = e;
|
||||
attemptMap.set(attemptKey, attempt);
|
||||
} else if (event.type === 'run.completed') {
|
||||
const e = event as RunCompletedEvent;
|
||||
attemptKey = e.attemptId || `${e.timestamp}_${e.agent}`;
|
||||
// Try to find matching started event
|
||||
let found = false;
|
||||
for (const [, attempt] of attemptMap.entries()) {
|
||||
if (attempt.started?.agent === e.agent && !attempt.completed) {
|
||||
attempt.completed = e;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
const attempt = attemptMap.get(attemptKey) || {};
|
||||
attempt.completed = e;
|
||||
attemptMap.set(attemptKey, attempt);
|
||||
}
|
||||
} else if (event.type === 'run.error') {
|
||||
const e = event as RunErrorEvent;
|
||||
attemptKey = e.attemptId || `${e.timestamp}_${e.agent}`;
|
||||
for (const [, attempt] of attemptMap.entries()) {
|
||||
if (attempt.started?.agent === e.agent && !attempt.error) {
|
||||
attempt.error = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'run.tokens') {
|
||||
const e = event as TokenTelemetryEvent;
|
||||
attemptKey = e.attemptId || `${e.timestamp}_${e.agent}`;
|
||||
// Associate with the most recent started event for this agent
|
||||
for (const [, attempt] of attemptMap.entries()) {
|
||||
if (attempt.started?.agent === e.agent && !attempt.tokens) {
|
||||
attempt.tokens = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no match, create standalone entry
|
||||
if (!attemptMap.has(attemptKey)) {
|
||||
attemptMap.set(attemptKey, { tokens: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to AttemptMetrics array
|
||||
const attempts: AttemptMetrics[] = [];
|
||||
|
||||
for (const [key, data] of attemptMap.entries()) {
|
||||
const attempt: AttemptMetrics = {
|
||||
attemptId: key,
|
||||
startTime: data.started?.timestamp || data.completed?.timestamp || data.tokens?.timestamp || '',
|
||||
endTime: data.completed?.timestamp,
|
||||
agent: data.started?.agent || data.completed?.agent || data.tokens?.agent || 'unknown',
|
||||
model: data.started?.model || data.tokens?.model,
|
||||
durationMs: data.completed?.durationMs,
|
||||
success: data.completed?.success,
|
||||
error: data.completed?.error || data.error?.error,
|
||||
inputTokens: data.tokens?.inputTokens || 0,
|
||||
outputTokens: data.tokens?.outputTokens || 0,
|
||||
cacheTokens: data.tokens?.cacheTokens || 0,
|
||||
totalTokens: data.tokens?.totalTokens || (data.tokens ? data.tokens.inputTokens + data.tokens.outputTokens : 0),
|
||||
cost: data.tokens?.cost || 0,
|
||||
};
|
||||
|
||||
if (attempt.startTime) {
|
||||
attempts.push(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time (newest first)
|
||||
attempts.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||
|
||||
// Calculate aggregates
|
||||
const runsWithOutcome = attempts.filter(a => a.success !== undefined);
|
||||
const successfulRuns = runsWithOutcome.filter(a => a.success === true).length;
|
||||
const failedRuns = runsWithOutcome.filter(a => a.success === false).length;
|
||||
const totalRuns = runsWithOutcome.length || attempts.length;
|
||||
|
||||
const durationsMs = attempts.filter(a => a.durationMs !== undefined).map(a => a.durationMs!);
|
||||
const totalDurationMs = durationsMs.reduce((sum, d) => sum + d, 0);
|
||||
const avgDurationMs = durationsMs.length > 0 ? totalDurationMs / durationsMs.length : 0;
|
||||
|
||||
const totalInputTokens = attempts.reduce((sum, a) => sum + a.inputTokens, 0);
|
||||
const totalOutputTokens = attempts.reduce((sum, a) => sum + a.outputTokens, 0);
|
||||
const totalCacheTokens = attempts.reduce((sum, a) => sum + a.cacheTokens, 0);
|
||||
const totalTokens = attempts.reduce((sum, a) => sum + a.totalTokens, 0);
|
||||
const totalCost = attempts.reduce((sum, a) => sum + a.cost, 0);
|
||||
|
||||
return {
|
||||
taskId,
|
||||
totalRuns,
|
||||
successfulRuns,
|
||||
failedRuns,
|
||||
successRate: totalRuns > 0 ? successfulRuns / totalRuns : 0,
|
||||
totalDurationMs,
|
||||
avgDurationMs,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalCacheTokens,
|
||||
totalTokens,
|
||||
totalCost,
|
||||
lastRun: attempts[0],
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTaskMetrics(taskId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['taskMetrics', taskId],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return null;
|
||||
const events = await fetchTaskTelemetry(taskId);
|
||||
return aggregateMetrics(taskId, events);
|
||||
},
|
||||
enabled: !!taskId,
|
||||
staleTime: 30000, // 30 seconds
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue