From 1624e4397f953711330af15fb30989b35d34a11b Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Mon, 1 Dec 2025 04:15:38 -0500 Subject: Add jr task log CLI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perfect! Both output modes work correctly. The task has been successfull 1. ✅ Basic log viewing: `jr task log ` 2. ✅ Session-specific viewing: `jr task log --session=` 3. ✅ Follow mode: `jr task log --follow` (polls every 500ms) 4. ✅ JSON output: `jr task log --json` 5. ✅ Human-readable formatting with timestamps 6. ✅ Proper event formatting for Assistant, ToolCall, ToolResult, Cost, 7. ✅ All tests pass 8. ✅ No lint or hlint issues The implementation was mostly complete when I started - I only needed to Task-Id: t-197.6 --- Omni/Jr/Web.hs | 5 +- Omni/Task.hs | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) (limited to 'Omni') diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs index 2be8ea1..d528365 100644 --- a/Omni/Jr/Web.hs +++ b/Omni/Jr/Web.hs @@ -19,6 +19,7 @@ where import Alpha import qualified Control.Concurrent as Concurrent import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy as LBS import qualified Data.List as List import qualified Data.Text as Text import qualified Data.Text.Lazy as LazyText @@ -261,7 +262,7 @@ instance Accept SSE where contentType _ = "text/event-stream" instance MimeRender SSE ByteString where - mimeRender _ = identity + mimeRender _ = LBS.fromStrict data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] Bool TaskCore.AggregatedMetrics TimeRange UTCTime @@ -2576,7 +2577,7 @@ streamAgentEvents tid sid = do streamEventsStep :: Text -> Text -> Int -> [ByteString] -> Bool -> Source.StepT IO ByteString streamEventsStep tid sid lastId buffer sendExisting = case (sendExisting, buffer) of -- Send buffered existing events first - (True, b : bs) -> pure <| Source.Yield b (streamEventsStep tid sid lastId bs True) + (True, b : bs) -> Source.Yield b (streamEventsStep tid sid lastId bs True) (True, []) -> streamEventsStep tid sid lastId [] False -- Poll for new events (False, _) -> diff --git a/Omni/Task.hs b/Omni/Task.hs index c6e68ac..11d080b 100644 --- a/Omni/Task.hs +++ b/Omni/Task.hs @@ -8,8 +8,11 @@ module Omni.Task where import Alpha import qualified Data.Aeson as Aeson +import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Lazy.Char8 as BLC import qualified Data.Text as T +import qualified Data.Text.Encoding as TE +import Data.Time (defaultTimeLocale, formatTime) import qualified Omni.Cli as Cli import qualified Omni.Namespace as Namespace import Omni.Task.Core @@ -54,6 +57,7 @@ Usage: task tree [] [--json] task progress [--json] task stats [--epic=] [--json] + task log [--session=] [--follow] [--json] task export [-o ] task import -i task test @@ -73,6 +77,7 @@ Commands: tree Show task tree (epics with children, or all epics if no ID given) progress Show progress for an epic stats Show task statistics + log Show agent event log for a task export Export tasks to JSONL import Import tasks from JSONL file test Run tests @@ -96,6 +101,8 @@ Options: --json Output in JSON format (for agent use) --quiet Non-interactive mode (for agents) --verified Mark task as verified (code compiles, tests pass, feature works) + --session= Show events for specific session ID + --follow Stream events in real-time (like tail -f) -i Input file for import -o Output file for export @@ -413,6 +420,13 @@ move' args stats <- getTaskStats maybeEpic outputJson stats else showTaskStats maybeEpic + | args `Cli.has` Cli.command "log" = do + tid <- getArgText args "id" + let maybeSession = T.pack pure Nothing @@ -437,6 +451,143 @@ move' args Nothing -> panic (T.pack name <> " required") Just val -> pure (T.pack val) +-- | Show task log for a given task ID and optional session +showTaskLog :: Text -> Maybe Text -> Bool -> IO () +showTaskLog tid maybeSession jsonMode = do + events <- case maybeSession of + Just sid -> getEventsForSession sid + Nothing -> getEventsForTask tid + + when (null events && not jsonMode) <| do + putText "No events found for this task." + + if jsonMode + then outputJson events + else traverse_ printEvent events + +-- | Follow task log in real-time (poll for new events) +followTaskLog :: Text -> Maybe Text -> IO () +followTaskLog tid maybeSession = do + -- Get session ID (use provided or get latest) + sid <- getSid + + -- Print initial events + events <- getEventsForSession sid + traverse_ printEvent events + + -- Start polling for new events + let lastEventId = if null events then 0 else maximum (map storedEventId events) + pollEvents sid lastEventId + where + getSid = case maybeSession of + Just s -> pure s + Nothing -> do + maybeSid <- getLatestSessionForTask tid + case maybeSid of + Nothing -> do + putText "No session found for this task. Waiting for events..." + threadDelay 1000000 + getSid -- Recursively retry + Just s -> pure s + + pollEvents sid lastId = do + threadDelay 500000 -- Poll every 500ms + newEvents <- getEventsSince sid lastId + unless (null newEvents) <| do + traverse_ printEvent newEvents + let newLastId = if null newEvents then lastId else maximum (map storedEventId newEvents) + pollEvents sid newLastId + +-- | Print a single event in human-readable format +printEvent :: StoredEvent -> IO () +printEvent event = do + let timestamp = storedEventTimestamp event + eventType = storedEventType event + content = storedEventContent event + + -- Format timestamp as HH:MM:SS + let timeStr = T.pack <| formatTime defaultTimeLocale "%H:%M:%S" timestamp + + -- Parse and format the content based on event type + let formatted = case eventType of + "Assistant" -> formatAssistant content + "ToolCall" -> formatToolCall content + "ToolResult" -> formatToolResult content + "Cost" -> formatCost content + "Error" -> formatError content + "Complete" -> "Complete" + _ -> eventType <> ": " <> content + + putText ("[" <> timeStr <> "] " <> formatted) + +-- Format Assistant messages +formatAssistant :: Text -> Text +formatAssistant content = + case Aeson.decode (BLC.pack <| T.unpack content) of + Just (Aeson.String msg) -> "Assistant: " <> truncateText 200 msg + _ -> "Assistant: " <> truncateText 200 content + +-- Format ToolCall events +formatToolCall :: Text -> Text +formatToolCall content = + case Aeson.decode (BLC.pack <| T.unpack content) of + Just (Aeson.String msg) -> "Tool: " <> msg + Just (Aeson.Object obj) -> + let toolName = case KM.lookup "tool" obj of + Just (Aeson.String n) -> n + _ -> "" + args = case KM.lookup "args" obj of + Just val -> " " <> TE.decodeUtf8 (BLC.toStrict (Aeson.encode val)) + _ -> "" + in "Tool: " <> toolName <> args + _ -> "Tool: " <> truncateText 100 content + +-- Format ToolResult events +formatToolResult :: Text -> Text +formatToolResult content = + case Aeson.decode (BLC.pack <| T.unpack content) of + Just (Aeson.Object obj) -> + let toolName = case KM.lookup "tool" obj of + Just (Aeson.String n) -> n + _ -> "" + success = case KM.lookup "success" obj of + Just (Aeson.Bool True) -> "ok" + Just (Aeson.Bool False) -> "failed" + _ -> "?" + output = case KM.lookup "output" obj of + Just (Aeson.String s) -> " (" <> tshow (T.length s) <> " bytes)" + _ -> "" + in "Result: " <> toolName <> " (" <> success <> ")" <> output + _ -> "Result: " <> truncateText 100 content + +-- Format Cost events +formatCost :: Text -> Text +formatCost content = + case Aeson.decode (BLC.pack <| T.unpack content) of + Just (Aeson.Object obj) -> + let tokens = case KM.lookup "tokens" obj of + Just (Aeson.Number n) -> tshow (round n :: Int) + _ -> "?" + cents = case KM.lookup "cents" obj of + Just (Aeson.Number n) -> tshow (round n :: Int) + _ -> "?" + in "Cost: " <> tokens <> " tokens, " <> cents <> " cents" + _ -> "Cost: " <> content + +-- Format Error events +formatError :: Text -> Text +formatError content = + case Aeson.decode (BLC.pack <| T.unpack content) of + Just (Aeson.String msg) -> "Error: " <> msg + _ -> "Error: " <> content + +-- Truncate text to a maximum length +truncateText :: Int -> Text -> Text +truncateText maxLen txt = + if T.length txt > maxLen + then T.take maxLen txt <> "..." + else txt + test :: Test.Tree test = Test.group @@ -1010,5 +1161,34 @@ cliTests = Left err -> Test.assertFailure <| "Failed to parse 'comment --json': " <> show err Right args -> do args `Cli.has` Cli.command "comment" Test.@?= True + args `Cli.has` Cli.longOption "json" Test.@?= True, + Test.unit "log command" <| do + let result = Docopt.parseArgs help ["log", "t-123"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'log': " <> show err + Right args -> do + args `Cli.has` Cli.command "log" Test.@?= True + Cli.getArg args (Cli.argument "id") Test.@?= Just "t-123", + Test.unit "log command with --session flag" <| do + let result = Docopt.parseArgs help ["log", "t-123", "--session=s-456"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'log --session': " <> show err + Right args -> do + args `Cli.has` Cli.command "log" Test.@?= True + Cli.getArg args (Cli.argument "id") Test.@?= Just "t-123" + Cli.getArg args (Cli.longOption "session") Test.@?= Just "s-456", + Test.unit "log command with --follow flag" <| do + let result = Docopt.parseArgs help ["log", "t-123", "--follow"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'log --follow': " <> show err + Right args -> do + args `Cli.has` Cli.command "log" Test.@?= True + args `Cli.has` Cli.longOption "follow" Test.@?= True, + Test.unit "log command with --json flag" <| do + let result = Docopt.parseArgs help ["log", "t-123", "--json"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'log --json': " <> show err + Right args -> do + args `Cli.has` Cli.command "log" Test.@?= True args `Cli.has` Cli.longOption "json" Test.@?= True ] -- cgit v1.2.3