summaryrefslogtreecommitdiff
path: root/Omni/Task.hs
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-01 04:15:38 -0500
committerBen Sima <ben@bensima.com>2025-12-01 04:15:38 -0500
commit1624e4397f953711330af15fb30989b35d34a11b (patch)
treef2b924356a20e5e83ef8af6e40f0749103dd3fc5 /Omni/Task.hs
parentf8eb55d38c5a7873133e01b0ecf7f07989f1f48b (diff)
Add jr task log CLI command
Perfect! Both output modes work correctly. The task has been successfull 1. ✅ Basic log viewing: `jr task log <id>` 2. ✅ Session-specific viewing: `jr task log <id> --session=<sid>` 3. ✅ Follow mode: `jr task log <id> --follow` (polls every 500ms) 4. ✅ JSON output: `jr task log <id> --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
Diffstat (limited to 'Omni/Task.hs')
-rw-r--r--Omni/Task.hs180
1 files changed, 180 insertions, 0 deletions
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 [<id>] [--json]
task progress <id> [--json]
task stats [--epic=<id>] [--json]
+ task log <id> [--session=<sid>] [--follow] [--json]
task export [-o <file>]
task import -i <file>
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=<sid> Show events for specific session ID
+ --follow Stream events in real-time (like tail -f)
-i <file> Input file for import
-o <file> 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 </ Cli.getArg args (Cli.longOption "session")
+ followMode = args `Cli.has` Cli.longOption "follow"
+ if followMode
+ then followTaskLog tid maybeSession
+ else showTaskLog tid maybeSession (isJsonMode args)
| args `Cli.has` Cli.command "export" = do
file <- case Cli.getArg args (Cli.shortOption 'o') of
Nothing -> 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
+ _ -> "<unknown>"
+ 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
+ _ -> "<unknown>"
+ 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
]