diff options
| author | Ben Sima <ben@bensima.com> | 2025-11-27 10:59:38 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-11-27 10:59:38 -0500 |
| commit | 83ff4b622be49762491dac216ab8df374b24cd74 (patch) | |
| tree | 6f8d85b640716c43a309ae41e4bb61dd233bdfef | |
| parent | 273208a8ffd714eb9cda51d557dbc62ff3009932 (diff) | |
Display worker metrics on task detail page
All tests pass. Let me summarize what was implemented:
- Extended `TaskActivity` type with new fields:
- `activityAmpThreadUrl` - Link to amp thread - `activityStartedAt` -
Work start timestamp - `activityCompletedAt` - Work completion
timestamp - `activityCostCents` - API cost in cents -
`activityTokensUsed` - Token usage count
- Updated `SQL.FromRow` and `SQL.ToRow` instances for the new fields -
Updated schema to include new columns in `task_activity` table - Added
`logActivityWithMetrics` function to log activities with all met -
Added `updateActivityMetrics` function to update metrics on existing
a - Added `getLatestRunningActivity` helper function
- Captures execution timing (start/end timestamps) - Retrieves amp
thread URL from `AgentLog.getStatus` - Converts credits to cents
and logs to activity record - Uses `logActivityWithMetrics` and
`updateActivityMetrics` for tracking
- Added `getStatus` function to retrieve current status (thread
URL, cre
- Added `TaskMetricsPartial` type for HTMX auto-refresh - Extended
`TaskDetailPage` to include `RetryContext` - Added Execution Details
section on task detail page showing:
- Amp Thread URL (clickable link) - Duration (formatted as "Xm Ys")
- Cost (formatted as "$X.XX") - Retry Attempt count (if applicable)
- Last Activity timestamp
- Added `/partials/task/:id/metrics` endpoint for HTMX auto-refresh
- Auto-refresh enabled while task is InProgress (every 5s) - Added
`renderExecutionDetails` helper function
- Added `executionDetailsStyles` for metric rows and execution section
- Added dark mode support for execution details section
Task-Id: t-148.4
| -rw-r--r-- | Omni/Agent/Log.hs | 4 | ||||
| -rw-r--r-- | Omni/Agent/Worker.hs | 15 | ||||
| -rw-r--r-- | Omni/Jr/Web.hs | 162 | ||||
| -rw-r--r-- | Omni/Jr/Web/Style.hs | 41 | ||||
| -rw-r--r-- | Omni/Task/Core.hs | 74 |
5 files changed, 276 insertions, 20 deletions
diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs index b1020c5..55bc1e2 100644 --- a/Omni/Agent/Log.hs +++ b/Omni/Agent/Log.hs @@ -72,6 +72,10 @@ update f = do modifyIORef' currentStatus f render +-- | Get the current status +getStatus :: IO Status +getStatus = readIORef currentStatus + -- | Set the activity message updateActivity :: Text -> IO () updateActivity msg = update (\s -> s {statusActivity = msg}) diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs index 2557d70..eef31f4 100644 --- a/Omni/Agent/Worker.hs +++ b/Omni/Agent/Worker.hs @@ -10,6 +10,7 @@ import qualified Data.ByteString.Lazy as BSL import qualified Data.Text as Text import qualified Data.Text.Encoding as TE import qualified Data.Text.IO as TIO +import qualified Data.Time import qualified Omni.Agent.Core as Core import qualified Omni.Agent.Log as AgentLog import qualified Omni.Task.Core as TaskCore @@ -83,12 +84,22 @@ processTask worker task = do TaskCore.updateTaskStatus tid TaskCore.InProgress [] say "[worker] Status -> InProgress" - -- Run Amp + -- Run Amp with timing say "[worker] Starting amp..." - TaskCore.logActivity tid TaskCore.Running Nothing + startTime <- Data.Time.getCurrentTime + activityId <- TaskCore.logActivityWithMetrics tid TaskCore.Running Nothing Nothing (Just startTime) Nothing Nothing Nothing (exitCode, output) <- runAmp repo task + endTime <- Data.Time.getCurrentTime say ("[worker] Amp exited with: " <> tshow exitCode) + -- Capture metrics from agent log (thread URL, credits) + status <- AgentLog.getStatus + let threadUrl = ("https://ampcode.com/threads/" <>) </ AgentLog.statusThread status + let costCents = Just <| floor (AgentLog.statusCredits status * 100) + + -- Update the activity record with metrics + TaskCore.updateActivityMetrics activityId threadUrl (Just endTime) costCents Nothing + case exitCode of Exit.ExitSuccess -> do TaskCore.logActivity tid TaskCore.Reviewing Nothing diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs index 3c24d71..5fc8126 100644 --- a/Omni/Jr/Web.hs +++ b/Omni/Jr/Web.hs @@ -21,10 +21,11 @@ import qualified Data.List as List import qualified Data.Text as Text import qualified Data.Text.Lazy as LazyText import qualified Data.Text.Lazy.Encoding as LazyText -import Data.Time (UTCTime, defaultTimeLocale, formatTime) +import Data.Time (UTCTime, defaultTimeLocale, diffUTCTime, formatTime) import qualified Lucid import qualified Lucid.Base as Lucid import qualified Network.Wai.Handler.Warp as Warp +import Numeric (showFFloat) import qualified Omni.Jr.Web.Style as Style import qualified Omni.Task.Core as TaskCore import Servant @@ -71,6 +72,7 @@ type API = :> QueryParam "priority" Text :> QueryParam "namespace" Text :> Get '[Lucid.HTML] TaskListPartial + :<|> "partials" :> "task" :> Capture "id" Text :> "metrics" :> Get '[Lucid.HTML] TaskMetricsPartial data CSS @@ -91,7 +93,7 @@ newtype InterventionPage = InterventionPage [TaskCore.Task] data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters data TaskDetailPage - = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity] + = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) | TaskDetailNotFound Text data TaskReviewPage @@ -113,6 +115,8 @@ data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text newtype TaskListPartial = TaskListPartial [TaskCore.Task] +data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) + newtype RejectForm = RejectForm (Maybe Text) instance FromForm RejectForm where @@ -402,7 +406,7 @@ instance Lucid.ToHtml TaskDetailPage where "The task " Lucid.code_ (Lucid.toHtml tid) " could not be found." - toHtml (TaskDetailFound task allTasks activities) = + toHtml (TaskDetailFound task allTasks activities maybeRetry) = Lucid.doctypehtml_ <| do pageHead (TaskCore.taskId task <> " - Jr") Lucid.body_ <| do @@ -492,6 +496,21 @@ instance Lucid.ToHtml TaskDetailPage where Lucid.ul_ [Lucid.class_ "child-list"] <| do traverse_ renderChild children + let hasRunningActivity = any (\a -> TaskCore.activityStage a == TaskCore.Running) activities + when hasRunningActivity <| do + let isInProgress = TaskCore.taskStatus task == TaskCore.InProgress + htmxAttrs = + [ Lucid.makeAttribute "hx-get" ("/partials/task/" <> TaskCore.taskId task <> "/metrics"), + Lucid.makeAttribute "hx-trigger" "every 5s", + Lucid.makeAttribute "hx-swap" "innerHTML" + ] + sectionAttrs = + [Lucid.class_ "execution-section", Lucid.id_ "execution-details"] + <> [attr | isInProgress, attr <- htmxAttrs] + Lucid.div_ sectionAttrs <| do + Lucid.h3_ "Execution Details" + renderExecutionDetails (TaskCore.taskId task) activities maybeRetry + when (TaskCore.taskStatus task == TaskCore.InProgress && not (null activities)) <| do Lucid.div_ [Lucid.class_ "activity-section"] <| do Lucid.h3_ "Activity Timeline" @@ -566,6 +585,64 @@ instance Lucid.ToHtml TaskDetailPage where formatTimestamp :: UTCTime -> Text formatTimestamp = Text.pack <. formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" + renderExecutionDetails :: (Monad m) => Text -> [TaskCore.TaskActivity] -> Maybe TaskCore.RetryContext -> Lucid.HtmlT m () + renderExecutionDetails _ acts retryCtx = + case findRunningAct acts of + Nothing -> Lucid.p_ [Lucid.class_ "empty-msg"] "No worker execution data available." + Just act -> + Lucid.div_ [Lucid.class_ "execution-details"] <| do + case TaskCore.activityAmpThreadUrl act of + Nothing -> pure () + Just url -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Amp Thread:" + Lucid.a_ [Lucid.href_ url, Lucid.target_ "_blank", Lucid.class_ "metric-value amp-link"] (Lucid.toHtml url) + + case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of + (Just start, Just end) -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Duration:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDur start end)) + (Just start, Nothing) -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Started:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTimestamp start)) + _ -> pure () + + case TaskCore.activityCostCents act of + Nothing -> pure () + Just cents -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Cost:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCostVal cents)) + + case retryCtx of + Nothing -> pure () + Just ctx -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Retry Attempt:" + Lucid.span_ [Lucid.class_ "metric-value retry-count"] (Lucid.toHtml (tshow (TaskCore.retryAttempt ctx) <> "/3")) + + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Last Activity:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTimestamp (TaskCore.activityTimestamp act))) + where + findRunningAct = List.find (\a -> TaskCore.activityStage a == TaskCore.Running) + + formatDur :: UTCTime -> UTCTime -> Text + formatDur start end = + let diffSecs = floor (diffUTCTime end start) :: Int + mins = diffSecs `div` 60 + secs = diffSecs `mod` 60 + in if mins > 0 + then tshow mins <> "m " <> tshow secs <> "s" + else tshow secs <> "s" + + formatCostVal :: Int -> Text + formatCostVal cents = + let dollars = fromIntegral cents / 100.0 :: Double + in "$" <> Text.pack (showFFloat (Just 2) dollars "") + instance Lucid.ToHtml TaskReviewPage where toHtmlRaw = Lucid.toHtml toHtml (ReviewPageNotFound tid) = @@ -772,6 +849,75 @@ instance Lucid.ToHtml TaskListPartial where then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters." else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks +instance Lucid.ToHtml TaskMetricsPartial where + toHtmlRaw = Lucid.toHtml + toHtml (TaskMetricsPartial _tid activities maybeRetry) = + Lucid.div_ [Lucid.class_ "execution-details"] <| do + case findRunningActivity activities of + Nothing -> Lucid.p_ [Lucid.class_ "empty-msg"] "No worker execution data available." + Just act -> do + case TaskCore.activityAmpThreadUrl act of + Nothing -> pure () + Just url -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Amp Thread:" + Lucid.a_ [Lucid.href_ url, Lucid.target_ "_blank", Lucid.class_ "metric-value amp-link"] (Lucid.toHtml url) + + case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of + (Just start, Just end) -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Duration:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDuration start end)) + (Just start, Nothing) -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Started:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTimestamp start)) + _ -> pure () + + case TaskCore.activityCostCents act of + Nothing -> pure () + Just cents -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Cost:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCost cents)) + + case TaskCore.activityTokensUsed act of + Nothing -> pure () + Just tokens -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Tokens:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (tshow tokens)) + + case maybeRetry of + Nothing -> pure () + Just ctx -> + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Retry Attempt:" + Lucid.span_ [Lucid.class_ "metric-value retry-count"] (Lucid.toHtml (tshow (TaskCore.retryAttempt ctx) <> "/3")) + + Lucid.div_ [Lucid.class_ "metric-row"] <| do + Lucid.span_ [Lucid.class_ "metric-label"] "Last Activity:" + Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTimestamp (TaskCore.activityTimestamp act))) + where + findRunningActivity = List.find (\a -> TaskCore.activityStage a == TaskCore.Running) + + formatTimestamp :: UTCTime -> Text + formatTimestamp = Text.pack <. formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" + + formatDuration :: UTCTime -> UTCTime -> Text + formatDuration start end = + let diffSecs = floor (diffUTCTime end start) :: Int + mins = diffSecs `div` 60 + secs = diffSecs `mod` 60 + in if mins > 0 + then tshow mins <> "m " <> tshow secs <> "s" + else tshow secs <> "s" + + formatCost :: Int -> Text + formatCost cents = + let dollars = fromIntegral cents / 100.0 :: Double + in "$" <> Text.pack (showFFloat (Just 2) dollars "") + -- | Simple markdown renderer for epic descriptions -- Supports: headers (#, ##, ###), lists (- or *), code blocks (```), inline code (`) renderMarkdown :: (Monad m) => Text -> Lucid.HtmlT m () @@ -904,6 +1050,7 @@ server = :<|> recentActivityHandler :<|> readyCountHandler :<|> taskListPartialHandler + :<|> taskMetricsPartialHandler where styleHandler :: Servant.Handler LazyText.Text styleHandler = pure Style.css @@ -988,7 +1135,8 @@ server = Nothing -> pure (TaskDetailNotFound tid) Just task -> do activities <- liftIO (TaskCore.getActivitiesForTask tid) - pure (TaskDetailFound task tasks activities) + retryCtx <- liftIO (TaskCore.getRetryContext tid) + pure (TaskDetailFound task tasks activities retryCtx) taskStatusHandler :: Text -> StatusForm -> Servant.Handler StatusBadgePartial taskStatusHandler tid (StatusForm newStatus) = do @@ -1056,6 +1204,12 @@ server = filteredTasks = applyFilters filters allTasks pure (TaskListPartial filteredTasks) + taskMetricsPartialHandler :: Text -> Servant.Handler TaskMetricsPartial + taskMetricsPartialHandler tid = do + activities <- liftIO (TaskCore.getActivitiesForTask tid) + maybeRetry <- liftIO (TaskCore.getRetryContext tid) + pure (TaskMetricsPartial tid activities maybeRetry) + getReviewInfo :: Text -> IO ReviewInfo getReviewInfo tid = do maybeCommit <- findCommitForTask tid diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs index dbe1daa..b4f4c76 100644 --- a/Omni/Jr/Web/Style.hs +++ b/Omni/Jr/Web/Style.hs @@ -28,6 +28,7 @@ stylesheet = do statusBadges buttonStyles formStyles + executionDetailsStyles activityTimelineStyles markdownStyles responsiveStyles @@ -497,6 +498,39 @@ formStyles = do ".form-actions" ? do marginTop (px 8) +executionDetailsStyles :: Css +executionDetailsStyles = do + ".execution-section" ? do + marginTop (em 1) + backgroundColor white + borderRadius (px 2) (px 2) (px 2) (px 2) + padding (px 8) (px 10) (px 8) (px 10) + border (px 1) solid "#d0d0d0" + ".execution-details" ? do + marginTop (px 8) + ".metric-row" ? do + display flex + flexWrap Flexbox.wrap + padding (px 4) (px 0) (px 4) (px 0) + borderBottom (px 1) solid "#e5e7eb" + ".metric-row" # lastChild ? borderBottom (px 0) none transparent + ".metric-label" ? do + fontWeight (weight 600) + width (px 120) + color "#6b7280" + fontSize (px 13) + ".metric-value" ? do + Stylesheet.key "flex" ("1" :: Text) + fontSize (px 13) + ".amp-link" ? do + color "#0066cc" + textDecoration none + wordBreak breakAll + ".amp-link" # hover ? textDecoration underline + ".retry-count" ? do + color "#f97316" + fontWeight (weight 600) + activityTimelineStyles :: Css activityTimelineStyles = do ".activity-section" ? do @@ -751,6 +785,13 @@ darkModeStyles = ".activity-message" ? color "#d1d5db" (".activity-metadata" |> "summary") ? color "#9ca3af" ".metadata-json" ? backgroundColor "#374151" + ".execution-section" ? do + backgroundColor "#1f2937" + borderColor "#374151" + ".metric-row" ? borderBottomColor "#374151" + ".metric-label" ? color "#9ca3af" + ".metric-value" ? color "#d1d5db" + ".amp-link" ? color "#60a5fa" ".markdown-content" ? color "#d1d5db" ".md-h1" ? borderBottomColor "#374151" ".md-inline-code" ? do diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index 3a71900..ffecd60 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -93,7 +93,12 @@ data TaskActivity = TaskActivity activityTimestamp :: UTCTime, activityStage :: ActivityStage, activityMessage :: Maybe Text, - activityMetadata :: Maybe Text -- JSON for extra data + activityMetadata :: Maybe Text, -- JSON for extra data + activityAmpThreadUrl :: Maybe Text, -- Link to amp thread + activityStartedAt :: Maybe UTCTime, -- When work started + activityCompletedAt :: Maybe UTCTime, -- When work completed + activityCostCents :: Maybe Int, -- API cost in cents + activityTokensUsed :: Maybe Int -- Total tokens used } deriving (Show, Eq, Generic) @@ -241,6 +246,11 @@ instance SQL.FromRow TaskActivity where <*> SQL.field <*> SQL.field <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field + <*> SQL.field instance SQL.ToRow TaskActivity where toRow a = @@ -249,7 +259,12 @@ instance SQL.ToRow TaskActivity where SQL.toField (activityTimestamp a), SQL.toField (activityStage a), SQL.toField (activityMessage a), - SQL.toField (activityMetadata a) + SQL.toField (activityMetadata a), + SQL.toField (activityAmpThreadUrl a), + SQL.toField (activityStartedAt a), + SQL.toField (activityCompletedAt a), + SQL.toField (activityCostCents a), + SQL.toField (activityTokensUsed a) ] -- | Case-insensitive ID comparison @@ -352,6 +367,11 @@ initTaskDb = do \ stage TEXT NOT NULL, \ \ message TEXT, \ \ metadata TEXT, \ + \ amp_thread_url TEXT, \ + \ started_at DATETIME, \ + \ completed_at DATETIME, \ + \ cost_cents INTEGER, \ + \ tokens_used INTEGER, \ \ FOREIGN KEY (task_id) REFERENCES tasks(id) \ \)" @@ -1011,21 +1031,47 @@ logActivity tid stage metadata = "INSERT INTO task_activity (task_id, stage, message, metadata) VALUES (?, ?, ?, ?)" (tid, show stage :: String, Nothing :: Maybe Text, metadata) +-- | Log activity with worker metrics (amp thread URL, timing, cost) +logActivityWithMetrics :: Text -> ActivityStage -> Maybe Text -> Maybe Text -> Maybe UTCTime -> Maybe UTCTime -> Maybe Int -> Maybe Int -> IO Int +logActivityWithMetrics tid stage metadata ampUrl startedAt completedAt costCents tokens = + withDb <| \conn -> do + SQL.execute + conn + "INSERT INTO task_activity (task_id, stage, message, metadata, amp_thread_url, started_at, completed_at, cost_cents, tokens_used) \ + \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + (tid, show stage :: String, Nothing :: Maybe Text, metadata, ampUrl, startedAt, completedAt, costCents, tokens) + [SQL.Only actId] <- SQL.query_ conn "SELECT last_insert_rowid()" :: IO [SQL.Only Int] + pure actId + +-- | Update an existing activity record with metrics +updateActivityMetrics :: Int -> Maybe Text -> Maybe UTCTime -> Maybe Int -> Maybe Int -> IO () +updateActivityMetrics actId ampUrl completedAt costCents tokens = + withDb <| \conn -> + SQL.execute + conn + "UPDATE task_activity SET amp_thread_url = COALESCE(?, amp_thread_url), \ + \completed_at = COALESCE(?, completed_at), \ + \cost_cents = COALESCE(?, cost_cents), \ + \tokens_used = COALESCE(?, tokens_used) \ + \WHERE id = ?" + (ampUrl, completedAt, costCents, tokens, actId) + -- | Get all activities for a task, ordered by timestamp descending getActivitiesForTask :: Text -> IO [TaskActivity] getActivitiesForTask tid = - withDb <| \conn -> do - rows <- - SQL.query - conn - "SELECT id, task_id, timestamp, stage, message, metadata \ - \FROM task_activity WHERE task_id = ? ORDER BY timestamp DESC" - (SQL.Only tid) :: - IO [(Int, Text, UTCTime, Text, Maybe Text, Maybe Text)] - pure [TaskActivity (Just i) taskId ts (readStage stg) msg meta | (i, taskId, ts, stg, msg, meta) <- rows] - where - readStage :: Text -> ActivityStage - readStage s = fromMaybe Claiming (readMaybe (T.unpack s)) + withDb <| \conn -> + SQL.query + conn + "SELECT id, task_id, timestamp, stage, message, metadata, \ + \amp_thread_url, started_at, completed_at, cost_cents, tokens_used \ + \FROM task_activity WHERE task_id = ? ORDER BY timestamp DESC" + (SQL.Only tid) + +-- | Get the most recent running activity for a task (for metrics display) +getLatestRunningActivity :: Text -> IO (Maybe TaskActivity) +getLatestRunningActivity tid = do + activities <- getActivitiesForTask tid + pure <| List.find (\a -> activityStage a == Running) activities -- | Get tasks with unmet blocking dependencies (not ready, not done) getBlockedTasks :: IO [Task] |
