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:
Brad Groux 2026-01-28 07:55:48 -06:00
parent 65e0c8b278
commit 22509f1808
16 changed files with 2546 additions and 2 deletions

View file

@ -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
View file

175
scripts/get-session-tokens.sh Executable file
View 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

View file

@ -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",

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View file

@ -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);
})
);

View file

@ -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;
}

View 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';

View file

@ -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;
}

View file

@ -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 */

View file

@ -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>

View 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>
);
}

View 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
});
}