summaryrefslogtreecommitdiff
path: root/Omni/Task/Core.hs
diff options
context:
space:
mode:
Diffstat (limited to 'Omni/Task/Core.hs')
-rw-r--r--Omni/Task/Core.hs499
1 files changed, 367 insertions, 132 deletions
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 228ab05..b17c2aa 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -18,6 +18,10 @@ import Data.Time.Calendar (toModifiedJulianDay)
import GHC.Generics ()
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.Environment (lookupEnv)
+import System.IO (SeekMode (AbsoluteSeek))
+import qualified System.IO as IO
+import System.IO.Unsafe (unsafePerformIO)
+import System.Posix.IO (LockRequest (..), closeFd, handleToFd, waitToSetLock)
-- Core data types
data Task = Task
@@ -29,6 +33,7 @@ data Task = Task
taskStatus :: Status,
taskPriority :: Priority, -- Priority level (0-4)
taskDependencies :: [Dependency], -- List of dependencies with types
+ taskDescription :: Maybe Text, -- Optional detailed description
taskCreatedAt :: UTCTime,
taskUpdatedAt :: UTCTime
}
@@ -57,6 +62,14 @@ data DependencyType
| Related -- Soft relationship, doesn't block
deriving (Show, Eq, Generic)
+data TaskProgress = TaskProgress
+ { progressTaskId :: Text,
+ progressTotal :: Int,
+ progressCompleted :: Int,
+ progressPercentage :: Int
+ }
+ deriving (Show, Eq, Generic)
+
instance ToJSON TaskType
instance FromJSON TaskType
@@ -81,15 +94,44 @@ instance ToJSON Task
instance FromJSON Task
+-- | Case-insensitive ID comparison
+matchesId :: Text -> Text -> Bool
+matchesId id1 id2 = normalizeId id1 == normalizeId id2
+
+-- | Normalize ID to lowercase
+normalizeId :: Text -> Text
+normalizeId = T.toLower
+
+-- | Find a task by ID (case-insensitive)
+findTask :: Text -> [Task] -> Maybe Task
+findTask tid = List.find (\t -> matchesId (taskId t) tid)
+
+-- | Normalize task IDs (self, parent, dependencies) to lowercase
+normalizeTask :: Task -> Task
+normalizeTask t =
+ t
+ { taskId = normalizeId (taskId t),
+ taskParent = fmap normalizeId (taskParent t),
+ taskDependencies = map normalizeDependency (taskDependencies t)
+ }
+
+normalizeDependency :: Dependency -> Dependency
+normalizeDependency d = d {depId = normalizeId (depId d)}
+
+instance ToJSON TaskProgress
+
+instance FromJSON TaskProgress
+
-- Get the tasks database file path (use test file if TASK_TEST_MODE is set)
getTasksFilePath :: IO FilePath
getTasksFilePath = do
customPath <- lookupEnv "TASK_DB_PATH"
testMode <- lookupEnv "TASK_TEST_MODE"
- pure <| case (customPath, testMode) of
- (Just path, _) -> path
- (_, Just "1") -> ".tasks/tasks-test.jsonl"
- _ -> ".tasks/tasks.jsonl"
+ let path = case (customPath, testMode) of
+ (Just p, _) -> p
+ (_, Just "1") -> ".tasks/tasks-test.jsonl"
+ _ -> ".tasks/tasks.jsonl"
+ pure path
-- Initialize the task database
initTaskDb :: IO ()
@@ -101,6 +143,55 @@ initTaskDb = do
TIO.writeFile tasksFile ""
putText <| "Initialized task database at " <> T.pack tasksFile
+-- Lock for in-process thread safety
+taskLock :: MVar ()
+taskLock = unsafePerformIO (newMVar ())
+{-# NOINLINE taskLock #-}
+
+-- Execute action with write lock (exclusive)
+withTaskWriteLock :: IO a -> IO a
+withTaskWriteLock action =
+ withMVar taskLock <| \_ -> do
+ -- In test mode, we rely on MVar for thread safety to avoid GHC "resource busy" errors
+ -- when mixing openFd (flock) and standard IO in threaded tests.
+ testMode <- lookupEnv "TASK_TEST_MODE"
+ case testMode of
+ Just "1" -> action
+ _ -> do
+ tasksFile <- getTasksFilePath
+ let lockFile = tasksFile <> ".lock"
+ bracket
+ ( do
+ h <- IO.openFile lockFile IO.ReadWriteMode
+ handleToFd h
+ )
+ closeFd
+ ( \fd -> do
+ waitToSetLock fd (WriteLock, AbsoluteSeek, 0, 0)
+ action
+ )
+
+-- Execute action with read lock (shared)
+withTaskReadLock :: IO a -> IO a
+withTaskReadLock action =
+ withMVar taskLock <| \_ -> do
+ testMode <- lookupEnv "TASK_TEST_MODE"
+ case testMode of
+ Just "1" -> action
+ _ -> do
+ tasksFile <- getTasksFilePath
+ let lockFile = tasksFile <> ".lock"
+ bracket
+ ( do
+ h <- IO.openFile lockFile IO.ReadWriteMode
+ handleToFd h
+ )
+ closeFd
+ ( \fd -> do
+ waitToSetLock fd (ReadLock, AbsoluteSeek, 0, 0)
+ action
+ )
+
-- Generate a short ID using base62 encoding of timestamp
generateId :: IO Text
generateId = do
@@ -113,14 +204,19 @@ generateId = do
-- Combine MJD and micros to ensure uniqueness across days.
-- Multiplier 10^11 (100,000 seconds) is safe for any day length.
totalMicros = (mjd * 100000000000) + micros
- encoded = toBase62 totalMicros
+ encoded = toBase36 totalMicros
pure <| "t-" <> T.pack encoded
-- Generate a child ID based on parent ID (e.g. "t-abc.1", "t-abc.1.2")
-- Finds the next available sequential suffix among existing children.
generateChildId :: Text -> IO Text
-generateChildId parentId = do
- tasks <- loadTasks
+generateChildId parentId =
+ withTaskReadLock <| do
+ tasks <- loadTasksInternal
+ pure <| computeNextChildId tasks (normalizeId parentId)
+
+computeNextChildId :: [Task] -> Text -> Text
+computeNextChildId tasks parentId =
-- Find the max suffix among ALL tasks that look like children (to avoid ID collisions)
-- We check all tasks, not just those with taskParent set, because we want to ensure
-- ID uniqueness even if the parent link is missing.
@@ -128,7 +224,7 @@ generateChildId parentId = do
nextSuffix = case suffixes of
[] -> 1
s -> maximum s + 1
- pure <| parentId <> "." <> T.pack (show nextSuffix)
+ in parentId <> "." <> T.pack (show nextSuffix)
getSuffix :: Text -> Text -> Maybe Int
getSuffix parent childId =
@@ -140,15 +236,15 @@ getSuffix parent childId =
else Nothing
else Nothing
--- Convert number to base62 (0-9, a-z, A-Z)
-toBase62 :: Integer -> String
-toBase62 0 = "0"
-toBase62 n = reverse <| go n
+-- Convert number to base36 (0-9, a-z)
+toBase36 :: Integer -> String
+toBase36 0 = "0"
+toBase36 n = reverse <| go n
where
- alphabet = ['0' .. '9'] ++ ['a' .. 'z'] ++ ['A' .. 'Z']
+ alphabet = ['0' .. '9'] ++ ['a' .. 'z']
go 0 = []
go x =
- let (q, r) = x `divMod` 62
+ let (q, r) = x `divMod` 36
idx = fromIntegral r
char = case drop idx alphabet of
(c : _) -> c
@@ -157,7 +253,10 @@ toBase62 n = reverse <| go n
-- Load all tasks from JSONL file (with migration support)
loadTasks :: IO [Task]
-loadTasks = do
+loadTasks = withTaskReadLock loadTasksInternal
+
+loadTasksInternal :: IO [Task]
+loadTasksInternal = do
tasksFile <- getTasksFilePath
exists <- doesFileExist tasksFile
if exists
@@ -173,11 +272,11 @@ loadTasks = do
then Nothing
else case decode (BLC.pack <| T.unpack line) of
Just task -> Just task
- Nothing -> migrateOldTask line
+ Nothing -> migrateTask line
- -- Migrate old task format (with taskProject field or missing priority) to new format
- migrateOldTask :: Text -> Maybe Task
- migrateOldTask line = case Aeson.decode (BLC.pack <| T.unpack line) :: Maybe Aeson.Object of
+ -- Migrate old task formats to new format
+ migrateTask :: Text -> Maybe Task
+ migrateTask line = case Aeson.decode (BLC.pack <| T.unpack line) :: Maybe Aeson.Object of
Nothing -> Nothing
Just obj ->
let taskId' = KM.lookup "taskId" obj +> parseMaybe Aeson.parseJSON
@@ -185,12 +284,22 @@ loadTasks = do
taskStatus' = KM.lookup "taskStatus" obj +> parseMaybe Aeson.parseJSON
taskCreatedAt' = KM.lookup "taskCreatedAt" obj +> parseMaybe Aeson.parseJSON
taskUpdatedAt' = KM.lookup "taskUpdatedAt" obj +> parseMaybe Aeson.parseJSON
- -- Extract old taskDependencies (could be [Text] or [Dependency])
- oldDeps = KM.lookup "taskDependencies" obj +> parseMaybe Aeson.parseJSON :: Maybe [Text]
- newDeps = maybe [] (map (\tid -> Dependency {depId = tid, depType = Blocks})) oldDeps
+
+ -- Extract taskDescription (new field)
+ taskDescription' = KM.lookup "taskDescription" obj +> parseMaybe Aeson.parseJSON
+
+ -- Extract dependencies (handle V1 [Dependency] and V0 [Text])
+ v1Deps = KM.lookup "taskDependencies" obj +> parseMaybe Aeson.parseJSON :: Maybe [Dependency]
+ v0Deps = KM.lookup "taskDependencies" obj +> parseMaybe Aeson.parseJSON :: Maybe [Text]
+ finalDeps = case v1Deps of
+ Just ds -> ds
+ Nothing -> case v0Deps of
+ Just ts -> map (\tid -> Dependency {depId = tid, depType = Blocks}) ts
+ Nothing -> []
+
-- taskProject is ignored in new format (use epics instead)
- taskType' = WorkTask -- Old tasks become WorkTask by default
- taskParent' = Nothing
+ taskType' = fromMaybe WorkTask (KM.lookup "taskType" obj +> parseMaybe Aeson.parseJSON)
+ taskParent' = KM.lookup "taskParent" obj +> parseMaybe Aeson.parseJSON
taskNamespace' = KM.lookup "taskNamespace" obj +> parseMaybe Aeson.parseJSON
-- Default priority to P2 (medium) for old tasks
taskPriority' = fromMaybe P2 (KM.lookup "taskPriority" obj +> parseMaybe Aeson.parseJSON)
@@ -205,7 +314,8 @@ loadTasks = do
taskNamespace = taskNamespace',
taskStatus = status,
taskPriority = taskPriority',
- taskDependencies = newDeps,
+ taskDependencies = finalDeps,
+ taskDescription = taskDescription',
taskCreatedAt = created,
taskUpdatedAt = updated
}
@@ -213,46 +323,59 @@ loadTasks = do
-- Save a single task (append to JSONL)
saveTask :: Task -> IO ()
-saveTask task = do
+saveTask = withTaskWriteLock <. saveTaskInternal
+
+saveTaskInternal :: Task -> IO ()
+saveTaskInternal task = do
tasksFile <- getTasksFilePath
let json = encode task
BLC.appendFile tasksFile (json <> "\n")
-- Create a new task
-createTask :: Text -> TaskType -> Maybe Text -> Maybe Text -> Priority -> [Dependency] -> IO Task
-createTask title taskType parent namespace priority deps = do
- tid <- maybe generateId generateChildId parent
- now <- getCurrentTime
- let task =
- Task
- { taskId = tid,
- taskTitle = title,
- taskType = taskType,
- taskParent = parent,
- taskNamespace = namespace,
- taskStatus = Open,
- taskPriority = priority,
- taskDependencies = deps,
- taskCreatedAt = now,
- taskUpdatedAt = now
- }
- saveTask task
- pure task
+createTask :: Text -> TaskType -> Maybe Text -> Maybe Text -> Priority -> [Dependency] -> Maybe Text -> IO Task
+createTask title taskType parent namespace priority deps description =
+ withTaskWriteLock <| do
+ let parent' = fmap normalizeId parent
+ deps' = map normalizeDependency deps
+
+ tid <- case parent' of
+ Nothing -> generateId
+ Just pid -> do
+ tasks <- loadTasksInternal
+ pure <| computeNextChildId tasks pid
+ now <- getCurrentTime
+ let task =
+ Task
+ { taskId = normalizeId tid,
+ taskTitle = title,
+ taskType = taskType,
+ taskParent = parent',
+ taskNamespace = namespace,
+ taskStatus = Open,
+ taskPriority = priority,
+ taskDependencies = deps',
+ taskDescription = description,
+ taskCreatedAt = now,
+ taskUpdatedAt = now
+ }
+ saveTaskInternal task
+ pure task
-- Update task status
-updateTaskStatus :: Text -> Status -> IO ()
-updateTaskStatus tid newStatus = do
- tasks <- loadTasks
- now <- getCurrentTime
- let updatedTasks = map updateIfMatch tasks
- updateIfMatch t =
- if taskId t == tid
- then t {taskStatus = newStatus, taskUpdatedAt = now}
- else t
- -- Rewrite the entire file (simple approach for MVP)
- tasksFile <- getTasksFilePath
- TIO.writeFile tasksFile ""
- traverse_ saveTask updatedTasks
+updateTaskStatus :: Text -> Status -> [Dependency] -> IO ()
+updateTaskStatus tid newStatus newDeps =
+ withTaskWriteLock <| do
+ tasks <- loadTasksInternal
+ now <- getCurrentTime
+ let updatedTasks = map updateIfMatch tasks
+ updateIfMatch t =
+ if matchesId (taskId t) tid
+ then t {taskStatus = newStatus, taskUpdatedAt = now, taskDependencies = if null newDeps then taskDependencies t else newDeps}
+ else t
+ -- Rewrite the entire file (simple approach for MVP)
+ tasksFile <- getTasksFilePath
+ TIO.writeFile tasksFile ""
+ traverse_ saveTaskInternal updatedTasks
-- List tasks, optionally filtered by type, parent, status, or namespace
listTasks :: Maybe TaskType -> Maybe Text -> Maybe Status -> Maybe Text -> IO [Task]
@@ -279,7 +402,9 @@ listTasks maybeType maybeParent maybeStatus maybeNamespace = do
getReadyTasks :: IO [Task]
getReadyTasks = do
allTasks <- loadTasks
- let openTasks = filter (\t -> taskStatus t /= Done) allTasks
+ -- Only Open or InProgress tasks are considered ready for work.
+ -- Review tasks are waiting for review, and Done tasks are complete.
+ let openTasks = filter (\t -> taskStatus t == Open || taskStatus t == InProgress) allTasks
doneIds = map taskId <| filter (\t -> taskStatus t == Done) allTasks
-- Find all tasks that act as parents
@@ -297,29 +422,56 @@ getReadyTasks = do
getDependencyTree :: Text -> IO [Task]
getDependencyTree tid = do
tasks <- loadTasks
- case filter (\t -> taskId t == tid) tasks of
- [] -> pure []
- (task : _) -> pure <| collectDeps tasks task
+ case findTask tid tasks of
+ Nothing -> pure []
+ Just task -> pure <| collectDeps tasks task
where
collectDeps :: [Task] -> Task -> [Task]
collectDeps allTasks task =
let depIds = map depId (taskDependencies task)
- deps = filter (\t -> taskId t `elem` depIds) allTasks
+ deps = filter (\t -> any (matchesId (taskId t)) depIds) allTasks
in task : concatMap (collectDeps allTasks) deps
+-- Get task progress
+getTaskProgress :: Text -> IO TaskProgress
+getTaskProgress tidRaw = do
+ let tid = normalizeId tidRaw
+ tasks <- loadTasks
+ -- Verify task exists (optional, but good for error handling)
+ case findTask tid tasks of
+ Nothing -> panic "Task not found"
+ Just _ -> do
+ let children = filter (\child -> taskParent child == Just tid) tasks
+ total = length children
+ completed = length <| filter (\child -> taskStatus child == Done) children
+ percentage = if total == 0 then 0 else (completed * 100) `div` total
+ pure
+ TaskProgress
+ { progressTaskId = tid,
+ progressTotal = total,
+ progressCompleted = completed,
+ progressPercentage = percentage
+ }
+
+-- Show task progress
+showTaskProgress :: Text -> IO ()
+showTaskProgress tid = do
+ progress <- getTaskProgress tid
+ putText <| "Progress for " <> tid <> ": " <> T.pack (show (progressCompleted progress)) <> "/" <> T.pack (show (progressTotal progress)) <> " (" <> T.pack (show (progressPercentage progress)) <> "%)"
+
-- Show dependency tree for a task
showDependencyTree :: Text -> IO ()
showDependencyTree tid = do
tasks <- loadTasks
- case filter (\t -> taskId t == tid) tasks of
- [] -> putText "Task not found"
- (task : _) -> printTree tasks task 0
+ case findTask tid tasks of
+ Nothing -> putText "Task not found"
+ Just task -> printTree tasks task 0
where
printTree :: [Task] -> Task -> Int -> IO ()
printTree allTasks task indent = do
putText <| T.pack (replicate (indent * 2) ' ') <> taskId task <> ": " <> taskTitle task
let depIds = map depId (taskDependencies task)
- deps = filter (\t -> taskId t `elem` depIds) allTasks
+ deps = filter (\t -> any (matchesId (taskId t)) depIds) allTasks
traverse_ (\dep -> printTree allTasks dep (indent + 1)) deps
-- Get task tree (returns tasks hierarchically)
@@ -333,13 +485,13 @@ getTaskTree maybeId = do
in pure <| concatMap (collectChildren tasks) epics
Just tid -> do
-- Return specific task/epic with its children
- case filter (\t -> taskId t == tid) tasks of
- [] -> pure []
- (task : _) -> pure <| collectChildren tasks task
+ case findTask tid tasks of
+ Nothing -> pure []
+ Just task -> pure <| collectChildren tasks task
where
collectChildren :: [Task] -> Task -> [Task]
collectChildren allTasks task =
- let children = filter (\t -> taskParent t == Just (taskId task)) allTasks
+ let children = filter (maybe False (`matchesId` taskId task) <. taskParent) allTasks
in task : concatMap (collectChildren allTasks) children
-- Show task tree (epic with children, or all epics if no ID given)
@@ -355,9 +507,9 @@ showTaskTree maybeId = do
else traverse_ (printEpicTree tasks) epics
Just tid -> do
-- Show specific task/epic with its children
- case filter (\t -> taskId t == tid) tasks of
- [] -> putText "Task not found"
- (task : _) -> printEpicTree tasks task
+ case findTask tid tasks of
+ Nothing -> putText "Task not found"
+ Just task -> printEpicTree tasks task
where
printEpicTree :: [Task] -> Task -> IO ()
printEpicTree allTasks task = printTreeNode allTasks task 0
@@ -367,7 +519,7 @@ showTaskTree maybeId = do
printTreeNode' :: [Task] -> Task -> Int -> [Bool] -> IO ()
printTreeNode' allTasks task indent ancestry = do
- let children = filter (\t -> taskParent t == Just (taskId task)) allTasks
+ let children = filter (maybe False (`matchesId` taskId task) <. taskParent) allTasks
-- Build tree prefix using box-drawing characters
prefix =
if indent == 0
@@ -387,9 +539,23 @@ showTaskTree maybeId = do
InProgress -> "[~]"
Review -> "[?]"
Done -> "[✓]"
+
+ coloredStatusStr = case taskType task of
+ Epic -> magenta statusStr
+ WorkTask -> case taskStatus task of
+ Open -> bold statusStr
+ InProgress -> yellow statusStr
+ Review -> magenta statusStr
+ Done -> green statusStr
+
nsStr = case taskNamespace task of
Nothing -> ""
Just ns -> "[" <> ns <> "] "
+
+ coloredNsStr = case taskNamespace task of
+ Nothing -> ""
+ Just _ -> gray nsStr
+
-- Calculate available width for title (80 cols - prefix - id - labels)
usedWidth = T.length prefix + T.length (taskId task) + T.length statusStr + T.length nsStr + 2
availableWidth = max 20 (80 - usedWidth)
@@ -397,7 +563,10 @@ showTaskTree maybeId = do
if T.length (taskTitle task) > availableWidth
then T.take (availableWidth - 3) (taskTitle task) <> "..."
else taskTitle task
- putText <| prefix <> taskId task <> " " <> statusStr <> " " <> nsStr <> truncatedTitle
+
+ coloredTitle = if taskType task == Epic then bold truncatedTitle else truncatedTitle
+
+ putText <| prefix <> cyan (taskId task) <> " " <> coloredStatusStr <> " " <> coloredNsStr <> coloredTitle
-- Print children with updated ancestry
let indexedChildren = zip [1 ..] children
@@ -416,29 +585,51 @@ printTask t = do
let progressInfo =
if taskType t == Epic
then
- let children = filter (\child -> taskParent child == Just (taskId t)) tasks
+ let children = filter (maybe False (`matchesId` taskId t) <. taskParent) tasks
total = length children
completed = length <| filter (\child -> taskStatus child == Done) children
in " [" <> T.pack (show completed) <> "/" <> T.pack (show total) <> "]"
else ""
+
parentInfo = case taskParent t of
Nothing -> ""
Just p -> " (parent: " <> p <> ")"
+
namespaceInfo = case taskNamespace t of
Nothing -> ""
Just ns -> " [" <> ns <> "]"
+
+ coloredStatus =
+ let s = "[" <> T.pack (show (taskStatus t)) <> "]"
+ in case taskStatus t of
+ Open -> bold s
+ InProgress -> yellow s
+ Review -> magenta s
+ Done -> green s
+
+ coloredTitle = if taskType t == Epic then bold (taskTitle t) else taskTitle t
+
+ coloredProgress = if taskType t == Epic then magenta progressInfo else progressInfo
+
+ coloredNamespace = case taskNamespace t of
+ Nothing -> ""
+ Just _ -> gray namespaceInfo
+
+ coloredParent = case taskParent t of
+ Nothing -> ""
+ Just _ -> gray parentInfo
+
putText
- <| taskId t
+ <| cyan (taskId t)
<> " ["
<> T.pack (show (taskType t))
- <> "] ["
- <> T.pack (show (taskStatus t))
- <> "]"
- <> progressInfo
+ <> "] "
+ <> coloredStatus
+ <> coloredProgress
<> " "
- <> taskTitle t
- <> parentInfo
- <> namespaceInfo
+ <> coloredTitle
+ <> coloredParent
+ <> coloredNamespace
-- Show detailed task information (human-readable)
showTaskDetailed :: Task -> IO ()
@@ -454,7 +645,7 @@ showTaskDetailed t = do
-- Show epic progress if this is an epic
when (taskType t == Epic) <| do
- let children = filter (\child -> taskParent child == Just (taskId t)) tasks
+ let children = filter (maybe False (`matchesId` taskId t) <. taskParent) tasks
total = length children
completed = length <| filter (\child -> taskStatus child == Done) children
percentage = if total == 0 then 0 else (completed * 100) `div` total
@@ -475,6 +666,16 @@ showTaskDetailed t = do
putText "Dependencies:"
traverse_ printDependency (taskDependencies t)
+ -- Show description
+ case taskDescription t of
+ Nothing -> pure ()
+ Just desc -> do
+ putText ""
+ putText "Description:"
+ -- Indent description for better readability
+ let indented = T.unlines <| map (" " <>) (T.lines desc)
+ putText indented
+
putText ""
where
priorityDesc = case taskPriority t of
@@ -487,14 +688,26 @@ showTaskDetailed t = do
printDependency dep =
putText <| " - " <> depId dep <> " [" <> T.pack (show (depType dep)) <> "]"
+-- ANSI Colors
+red, green, yellow, blue, magenta, cyan, gray, bold :: Text -> Text
+red t = "\ESC[31m" <> t <> "\ESC[0m"
+green t = "\ESC[32m" <> t <> "\ESC[0m"
+yellow t = "\ESC[33m" <> t <> "\ESC[0m"
+blue t = "\ESC[34m" <> t <> "\ESC[0m"
+magenta t = "\ESC[35m" <> t <> "\ESC[0m"
+cyan t = "\ESC[36m" <> t <> "\ESC[0m"
+gray t = "\ESC[90m" <> t <> "\ESC[0m"
+bold t = "\ESC[1m" <> t <> "\ESC[0m"
+
-- Export tasks: Consolidate JSONL file (remove duplicates, keep latest version)
exportTasks :: IO ()
-exportTasks = do
- tasks <- loadTasks
- -- Rewrite the entire file with deduplicated tasks
- tasksFile <- getTasksFilePath
- TIO.writeFile tasksFile ""
- traverse_ saveTask tasks
+exportTasks =
+ withTaskWriteLock <| do
+ tasks <- loadTasksInternal
+ -- Rewrite the entire file with deduplicated tasks
+ tasksFile <- getTasksFilePath
+ TIO.writeFile tasksFile ""
+ traverse_ saveTaskInternal tasks
-- Task statistics
data TaskStats = TaskStats
@@ -516,18 +729,31 @@ instance ToJSON TaskStats
instance FromJSON TaskStats
-- Get task statistics
-getTaskStats :: IO TaskStats
-getTaskStats = do
- tasks <- loadTasks
- ready <- getReadyTasks
- let total = length tasks
+getTaskStats :: Maybe Text -> IO TaskStats
+getTaskStats maybeEpicId = do
+ allTasks <- loadTasks
+
+ targetTasks <- case maybeEpicId of
+ Nothing -> pure allTasks
+ Just epicId ->
+ case findTask epicId allTasks of
+ Nothing -> panic "Epic not found"
+ Just task -> pure <| getAllDescendants allTasks (taskId task)
+
+ globalReady <- getReadyTasks
+ let readyIds = map taskId globalReady
+ -- Filter ready tasks to only include those in our target set
+ readyCount = length <| filter (\t -> taskId t `elem` readyIds) targetTasks
+
+ tasks = targetTasks
+ total = length tasks
open = length <| filter (\t -> taskStatus t == Open) tasks
inProg = length <| filter (\t -> taskStatus t == InProgress) tasks
review = length <| filter (\t -> taskStatus t == Review) tasks
done = length <| filter (\t -> taskStatus t == Done) tasks
epics = length <| filter (\t -> taskType t == Epic) tasks
- readyCount = length ready
- blockedCount = total - readyCount - done
+ readyCount' = readyCount
+ blockedCount = total - readyCount' - done
-- Count tasks by priority
byPriority =
[ (P0, length <| filter (\t -> taskPriority t == P0) tasks),
@@ -548,18 +774,26 @@ getTaskStats = do
reviewTasks = review,
doneTasks = done,
totalEpics = epics,
- readyTasks = readyCount,
+ readyTasks = readyCount',
blockedTasks = blockedCount,
tasksByPriority = byPriority,
tasksByNamespace = byNamespace
}
+-- Helper to get all descendants of a task (recursive)
+getAllDescendants :: [Task] -> Text -> [Task]
+getAllDescendants allTasks parentId =
+ let children = filter (maybe False (`matchesId` parentId) <. taskParent) allTasks
+ in children ++ concatMap (getAllDescendants allTasks <. taskId) children
+
-- Show task statistics (human-readable)
-showTaskStats :: IO ()
-showTaskStats = do
- stats <- getTaskStats
+showTaskStats :: Maybe Text -> IO ()
+showTaskStats maybeEpicId = do
+ stats <- getTaskStats maybeEpicId
putText ""
- putText "Task Statistics"
+ case maybeEpicId of
+ Nothing -> putText "Task Statistics"
+ Just epicId -> putText <| "Task Statistics for Epic " <> epicId
putText ""
putText <| "Total tasks: " <> T.pack (show (totalTasks stats))
putText <| " Open: " <> T.pack (show (openTasks stats))
@@ -593,31 +827,32 @@ showTaskStats = do
-- Import tasks: Read from another JSONL file and merge with existing tasks
importTasks :: FilePath -> IO ()
-importTasks filePath = do
- exists <- doesFileExist filePath
- unless exists <| panic (T.pack filePath <> " does not exist")
-
- -- Load tasks from import file
- content <- TIO.readFile filePath
- let importLines = T.lines content
- importedTasks = mapMaybe decodeTask importLines
-
- -- Load existing tasks
- existingTasks <- loadTasks
-
- -- Create a map of existing task IDs for quick lookup
- let existingIds = map taskId existingTasks
- -- Filter to only new tasks (not already in our database)
- newTasks = filter (\t -> taskId t `notElem` existingIds) importedTasks
- -- For tasks that exist, update them with imported data
- updatedTasks = map (updateWithImported importedTasks) existingTasks
- -- Combine: updated existing tasks + new tasks
- allTasks = updatedTasks ++ newTasks
-
- -- Rewrite tasks.jsonl with merged data
- tasksFile <- getTasksFilePath
- TIO.writeFile tasksFile ""
- traverse_ saveTask allTasks
+importTasks filePath =
+ withTaskWriteLock <| do
+ exists <- doesFileExist filePath
+ unless exists <| panic (T.pack filePath <> " does not exist")
+
+ -- Load tasks from import file
+ content <- TIO.readFile filePath
+ let importLines = T.lines content
+ importedTasks = map normalizeTask (mapMaybe decodeTask importLines)
+
+ -- Load existing tasks
+ existingTasks <- loadTasksInternal
+
+ -- Create a map of existing task IDs for quick lookup
+ let existingIds = map taskId existingTasks
+ -- Filter to only new tasks (not already in our database)
+ newTasks = filter (\t -> not (any (`matchesId` taskId t) existingIds)) importedTasks
+ -- For tasks that exist, update them with imported data
+ updatedTasks = map (updateWithImported importedTasks) existingTasks
+ -- Combine: updated existing tasks + new tasks
+ allTasks = updatedTasks ++ newTasks
+
+ -- Rewrite tasks.jsonl with merged data
+ tasksFile <- getTasksFilePath
+ TIO.writeFile tasksFile ""
+ traverse_ saveTaskInternal allTasks
where
decodeTask :: Text -> Maybe Task
decodeTask line =
@@ -628,9 +863,9 @@ importTasks filePath = do
-- Update an existing task if there's a newer version in imported tasks
updateWithImported :: [Task] -> Task -> Task
updateWithImported imported existing =
- case filter (\t -> taskId t == taskId existing) imported of
- [] -> existing -- No imported version, keep existing
- (importedTask : _) ->
+ case findTask (taskId existing) imported of
+ Nothing -> existing -- No imported version, keep existing
+ Just importedTask ->
-- Use imported version if it's newer (based on updatedAt)
if taskUpdatedAt importedTask > taskUpdatedAt existing
then importedTask