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.hs108
1 files changed, 87 insertions, 21 deletions
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 6856a83..066ad95 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
@@ -106,10 +110,11 @@ 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 ()
@@ -121,6 +126,53 @@ 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
@@ -139,8 +191,12 @@ generateId = do
-- 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 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.
@@ -148,7 +204,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 =
@@ -177,7 +233,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
@@ -233,15 +292,22 @@ 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
+createTask title taskType parent namespace priority deps = withTaskWriteLock <| do
+ tid <- case parent of
+ Nothing -> generateId
+ Just pid -> do
+ tasks <- loadTasksInternal
+ pure <| computeNextChildId tasks pid
now <- getCurrentTime
let task =
Task
@@ -256,13 +322,13 @@ createTask title taskType parent namespace priority deps = do
taskCreatedAt = now,
taskUpdatedAt = now
}
- saveTask task
+ saveTaskInternal task
pure task
-- Update task status
updateTaskStatus :: Text -> Status -> IO ()
-updateTaskStatus tid newStatus = do
- tasks <- loadTasks
+updateTaskStatus tid newStatus = withTaskWriteLock <| do
+ tasks <- loadTasksInternal
now <- getCurrentTime
let updatedTasks = map updateIfMatch tasks
updateIfMatch t =
@@ -272,7 +338,7 @@ updateTaskStatus tid newStatus = do
-- Rewrite the entire file (simple approach for MVP)
tasksFile <- getTasksFilePath
TIO.writeFile tasksFile ""
- traverse_ saveTask updatedTasks
+ traverse_ saveTaskInternal updatedTasks
-- List tasks, optionally filtered by type, parent, status, or namespace
listTasks :: Maybe TaskType -> Maybe Text -> Maybe Status -> Maybe Text -> IO [Task]
@@ -587,12 +653,12 @@ bold t = "\ESC[1m" <> t <> "\ESC[0m"
-- Export tasks: Consolidate JSONL file (remove duplicates, keep latest version)
exportTasks :: IO ()
-exportTasks = do
- tasks <- loadTasks
+exportTasks = withTaskWriteLock <| do
+ tasks <- loadTasksInternal
-- Rewrite the entire file with deduplicated tasks
tasksFile <- getTasksFilePath
TIO.writeFile tasksFile ""
- traverse_ saveTask tasks
+ traverse_ saveTaskInternal tasks
-- Task statistics
data TaskStats = TaskStats
@@ -712,7 +778,7 @@ showTaskStats maybeEpicId = do
-- Import tasks: Read from another JSONL file and merge with existing tasks
importTasks :: FilePath -> IO ()
-importTasks filePath = do
+importTasks filePath = withTaskWriteLock <| do
exists <- doesFileExist filePath
unless exists <| panic (T.pack filePath <> " does not exist")
@@ -722,7 +788,7 @@ importTasks filePath = do
importedTasks = mapMaybe decodeTask importLines
-- Load existing tasks
- existingTasks <- loadTasks
+ existingTasks <- loadTasksInternal
-- Create a map of existing task IDs for quick lookup
let existingIds = map taskId existingTasks
@@ -736,7 +802,7 @@ importTasks filePath = do
-- Rewrite tasks.jsonl with merged data
tasksFile <- getTasksFilePath
TIO.writeFile tasksFile ""
- traverse_ saveTask allTasks
+ traverse_ saveTaskInternal allTasks
where
decodeTask :: Text -> Maybe Task
decodeTask line =