diff options
Diffstat (limited to 'Omni')
| -rw-r--r-- | Omni/Agent/Log.hs | 96 | ||||
| -rw-r--r-- | Omni/Agent/LogTest.hs | 78 | ||||
| -rw-r--r-- | Omni/Agent/Worker.hs | 109 | ||||
| -rwxr-xr-x | Omni/Bild/Audit.py | 176 | ||||
| -rw-r--r-- | Omni/Task.hs | 32 | ||||
| -rw-r--r-- | Omni/Task/Core.hs | 56 |
6 files changed, 392 insertions, 155 deletions
diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs index 2e26272..dd66abc 100644 --- a/Omni/Agent/Log.hs +++ b/Omni/Agent/Log.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE NoImplicitPrelude #-} @@ -6,11 +7,28 @@ module Omni.Agent.Log where import Alpha +import Data.Aeson (Value (..), decode) +import qualified Data.Aeson.KeyMap as KM +import qualified Data.ByteString.Lazy as BL import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import qualified Data.Text.Encoding as TextEnc import qualified Data.Text.IO as TIO +import qualified Data.Vector as V import qualified System.Console.ANSI as ANSI import qualified System.IO as IO import System.IO.Unsafe (unsafePerformIO) +import Text.Printf (printf) + +-- | Parsed log entry +data LogEntry = LogEntry + { leMessage :: Maybe Text, + leLevel :: Maybe Text, + leToolName :: Maybe Text, + leBatches :: Maybe [[Text]], + leMethod :: Maybe Text, + lePath :: Maybe Text + } + deriving (Show, Eq) -- | Status of the agent for the UI data Status = Status @@ -60,6 +78,82 @@ update f = do updateActivity :: Text -> IO () updateActivity msg = update (\s -> s {statusActivity = msg}) +-- | Process a log line from the agent and update status if relevant +processLogLine :: Text -> IO () +processLogLine line = do + let entry = parseLine line + for_ (entry +> formatLogEntry) updateActivity + +-- | Parse a JSON log line into a LogEntry +parseLine :: Text -> Maybe LogEntry +parseLine line = do + let lbs = BL.fromStrict (TextEnc.encodeUtf8 line) + obj <- decode lbs + case obj of + Object o -> + Just + LogEntry + { leMessage = getString "message" o, + leLevel = getString "level" o, + leToolName = getString "toolName" o, + leBatches = getBatches o, + leMethod = getString "method" o, + lePath = getString "path" o + } + _ -> Nothing + where + getString k o = + case KM.lookup k o of + Just (String s) -> Just s + _ -> Nothing + + getBatches o = + case KM.lookup "batches" o of + Just (Array b) -> + Just + <| mapMaybe + ( \case + Array b0 -> + Just + <| mapMaybe + ( \case + String s -> Just s + _ -> Nothing + ) + (V.toList b0) + _ -> Nothing + ) + (V.toList b) + _ -> Nothing + +-- | Format a log entry into a user-friendly status message (NO EMOJIS) +formatLogEntry :: LogEntry -> Maybe Text +formatLogEntry LogEntry {..} = + case leMessage of + Just "executing 1 tools in 1 batch(es)" -> do + let tools = fromMaybe [] leBatches + let firstTool = case tools of + ((t : _) : _) -> t + _ -> "unknown" + Just ("THOUGHT: Planning tool execution (" <> firstTool <> ")") + Just "Tool Bash permitted - action: allow" -> + Just "TOOL: Bash command executed" + Just "Processing tool completion for ledger" + | isJust leToolName -> + Just ("TOOL: " <> fromMaybe "unknown" leToolName <> " completed") + Just "ide-fs" | leMethod == Just "readFile" -> + case lePath of + Just p -> Just ("READ: " <> p) + _ -> Nothing + Just "System prompt build complete (no changes)" -> + Just "THINKING..." + Just "System prompt build complete (first build)" -> + Just "STARTING new task context" + Just msg + | leLevel == Just "error" -> + Just ("ERROR: " <> msg) + _ -> Nothing + -- | Log a scrolling message (appears above status bars) log :: Text -> IO () log msg = do @@ -111,7 +205,7 @@ render = do ANSI.hCursorDown IO.stderr 1 ANSI.hSetCursorColumn IO.stderr 0 ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr <| "Credits: $" <> tshow statusCredits + TIO.hPutStr IO.stderr <| "Credits: $" <> str (printf "%.2f" statusCredits :: String) -- Line 5: Activity ANSI.hCursorDown IO.stderr 1 diff --git a/Omni/Agent/LogTest.hs b/Omni/Agent/LogTest.hs index 518147e..97b558d 100644 --- a/Omni/Agent/LogTest.hs +++ b/Omni/Agent/LogTest.hs @@ -5,7 +5,6 @@ module Omni.Agent.LogTest where import Alpha -import qualified Data.Set as Set import Omni.Agent.Log import qualified Omni.Test as Test @@ -17,9 +16,7 @@ tests = Test.group "Omni.Agent.Log" [ Test.unit "Parse LogEntry" testParse, - Test.unit "Format LogEntry" testFormat, - Test.unit "Update Status" testUpdateStatus, - Test.unit "Render Status" testRenderStatus + Test.unit "Format LogEntry" testFormat ] testParse :: IO () @@ -27,13 +24,12 @@ testParse = do let json = "{\"message\": \"executing 1 tools in 1 batch(es)\", \"batches\": [[\"grep\"]]}" let expected = LogEntry - { leMessage = "executing 1 tools in 1 batch(es)", + { leMessage = Just "executing 1 tools in 1 batch(es)", leLevel = Nothing, leToolName = Nothing, leBatches = Just [["grep"]], leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } parseLine json @?= Just expected @@ -41,84 +37,38 @@ testFormat :: IO () testFormat = do let entry = LogEntry - { leMessage = "executing 1 tools in 1 batch(es)", + { leMessage = Just "executing 1 tools in 1 batch(es)", leLevel = Nothing, leToolName = Nothing, leBatches = Just [["grep"]], leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } - format entry @?= Just "🤖 THOUGHT: Planning tool execution (grep)" + -- Expect NO emoji + formatLogEntry entry @?= Just "THOUGHT: Planning tool execution (grep)" let entry2 = LogEntry - { leMessage = "some random log", + { leMessage = Just "some random log", leLevel = Nothing, leToolName = Nothing, leBatches = Nothing, leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } - format entry2 @?= Nothing + formatLogEntry entry2 @?= Nothing let entry3 = LogEntry - { leMessage = "some error", + { leMessage = Just "some error", leLevel = Just "error", leToolName = Nothing, leBatches = Nothing, leMethod = Nothing, - lePath = Nothing, - leTimestamp = Nothing + lePath = Nothing } - format entry3 @?= Just "❌ ERROR: some error" - -testUpdateStatus :: IO () -testUpdateStatus = do - let s0 = initialStatus "worker-1" - let e1 = - LogEntry - { leMessage = "executing 1 tools in 1 batch(es)", - leLevel = Nothing, - leToolName = Nothing, - leBatches = Just [["grep"]], - leMethod = Nothing, - lePath = Nothing, - leTimestamp = Just "12:00:00" - } - let s1 = updateStatus e1 s0 - sLastActivity s1 @?= "🤖 THOUGHT: Planning tool execution (grep)" - sStartTime s1 @?= Just "12:00:00" - - let e2 = - LogEntry - { leMessage = "ide-fs", - leLevel = Nothing, - leToolName = Nothing, - leBatches = Nothing, - leMethod = Just "readFile", - lePath = Just "/path/to/file", - leTimestamp = Just "12:00:01" - } - let s2 = updateStatus e2 s1 - sLastActivity s2 @?= "📂 READ: /path/to/file" - Set.member "/path/to/file" (sFiles s2) @?= True - sStartTime s2 @?= Just "12:00:00" -- Should preserve start time - -testRenderStatus :: IO () -testRenderStatus = do - let s = - Status - { sWorkerName = "worker-1", - sTaskId = Just "t-123", - sFiles = Set.fromList ["file1", "file2"], - sStartTime = Just "12:00", - sLastActivity = "Running..." - } - let output = renderStatus s - output @?= "[Worker: worker-1] Task: t-123 | Files: 2\nRunning..." + -- Expect NO emoji + formatLogEntry entry3 @?= Just "ERROR: some error" (@?=) :: (Eq a, Show a) => a -> a -> IO () (@?=) = (Test.@?=) diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs index 4ff9042..1cc0b8d 100644 --- a/Omni/Agent/Worker.hs +++ b/Omni/Agent/Worker.hs @@ -5,12 +5,8 @@ module Omni.Agent.Worker where import Alpha -import qualified Data.Aeson as Aeson -import qualified Data.Aeson.KeyMap as KM -import qualified Data.ByteString.Lazy as BL -import qualified Data.Scientific as Scientific import qualified Data.Text as Text -import qualified Data.Time as Time +import qualified Data.Text.IO as TIO import qualified Omni.Agent.Core as Core import qualified Omni.Agent.Git as Git import qualified Omni.Agent.Log as AgentLog @@ -93,7 +89,7 @@ processTask worker task = do -- Run Amp AgentLog.updateActivity "Running Amp agent..." - exitCode <- runAmp repo task + (exitCode, output) <- runAmp repo task case exitCode of Exit.ExitSuccess -> do @@ -103,8 +99,10 @@ processTask worker task = do TaskCore.updateTaskStatus tid TaskCore.Review [] -- Commit changes - -- We should check if there are changes, but 'git add .' is safe. - Git.commit repo ("feat: implement " <> tid) + -- We use the agent's output as the extended commit description + let summary = Text.strip output + let commitMsg = "feat: implement " <> tid <> "\n\n" <> summary + Git.commit repo commitMsg -- Submit for review AgentLog.updateActivity "Submitting for review..." @@ -127,7 +125,7 @@ processTask worker task = do AgentLog.updateActivity "Agent failed, retrying..." threadDelay (10 * 1000000) -- Sleep 10s -runAmp :: FilePath -> TaskCore.Task -> IO Exit.ExitCode +runAmp :: FilePath -> TaskCore.Task -> IO (Exit.ExitCode, Text) runAmp repo task = do let prompt = "You are a Worker Agent.\n" @@ -139,7 +137,8 @@ runAmp repo task = do <> "3. Run tests to verify your work (e.g., 'bild --test Omni/Namespace').\n" <> "4. Fix any errors found during testing.\n" <> "5. Do NOT update the task status or manage git branches (the system handles that).\n" - <> "6. When finished and tested, exit.\n\n" + <> "6. Do NOT run 'git commit'. The system will commit your changes automatically.\n" + <> "7. When finished and tested, exit.\n\n" <> "Context:\n" <> "- You are working in '" <> Text.pack repo @@ -149,28 +148,37 @@ runAmp repo task = do <> "'.\n" Directory.createDirectoryIfMissing True (repo </> "_/llm") - let logFile = repo </> "_/llm/amp.log" + let logPath = repo </> "_/llm/amp.log" - -- Clean up previous log - exists <- Directory.doesFileExist logFile - when exists (Directory.removeFile logFile) + -- Ensure log file is empty/exists + IO.writeFile logPath "" - -- Start background monitors - tidTime <- forkIO timeTicker - tidLog <- forkIO (monitorLog logFile) + -- Read AGENTS.md + agentsMd <- + fmap (fromMaybe "") <| do + exists <- Directory.doesFileExist (repo </> "AGENTS.md") + if exists + then Just </ readFile (repo </> "AGENTS.md") + else pure Nothing + + let fullPrompt = + prompt + <> "\n\nREPOSITORY GUIDELINES (AGENTS.md):\n" + <> agentsMd + + -- Monitor log file + tidLog <- forkIO (monitorLog logPath) -- Assume amp is in PATH - let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack prompt] + let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack fullPrompt] let cp = (Process.proc "amp" args) {Process.cwd = Just repo} - (_, _, _, ph) <- Process.createProcess cp - exitCode <- Process.waitForProcess ph + (exitCode, out, _err) <- Process.readCreateProcessWithExitCode cp "" -- Cleanup - killThread tidTime killThread tidLog - pure exitCode + pure (exitCode, Text.pack out) formatTask :: TaskCore.Task -> Text formatTask t = @@ -224,56 +232,21 @@ findBaseBranch repo task = do [] -> pure "live" monitorLog :: FilePath -> IO () -monitorLog logPath = do - waitForFile logPath - IO.withFile logPath IO.ReadMode <| \h -> do - -- Tail the file - IO.hSeek h IO.SeekFromEnd 0 +monitorLog path = do + -- Wait for file to exist + waitForFile path + + IO.withFile path IO.ReadMode <| \h -> do + IO.hSetBuffering h IO.LineBuffering forever <| do eof <- IO.hIsEOF h if eof then threadDelay 100000 -- 0.1s else do - line <- IO.hGetLine h - parseAndUpdate (Text.pack line) + line <- TIO.hGetLine h + AgentLog.processLogLine line waitForFile :: FilePath -> IO () -waitForFile path = do - exists <- Directory.doesFileExist path - if exists - then pure () - else do - threadDelay 100000 - waitForFile path - -parseAndUpdate :: Text -> IO () -parseAndUpdate line = do - let maybeObj = Aeson.decode (BL.fromStrict (encodeUtf8 line)) :: Maybe Aeson.Object - case maybeObj of - Nothing -> pure () - Just obj -> do - -- Extract message (was msg) - case KM.lookup "message" obj of - Just (Aeson.String m) -> unless (Text.null m) (AgentLog.updateActivity m) - _ -> pure () - - -- Extract threadId - case KM.lookup "threadId" obj of - Just (Aeson.String tid) -> AgentLog.update (\s -> s {AgentLog.statusThreadId = Just tid}) - _ -> pure () - - -- Extract cost from usage-ledger:event - -- Pattern: {"addedCredits": 0.123, "message": "usage-ledger:event", ...} - case KM.lookup "addedCredits" obj of - Just (Aeson.Number n) -> - let cost = Scientific.toRealFloat n - in AgentLog.update (\s -> s {AgentLog.statusCredits = AgentLog.statusCredits s + cost}) - _ -> pure () - -timeTicker :: IO () -timeTicker = - forever <| do - time <- Time.getCurrentTime - let timeStr = Time.formatTime Time.defaultTimeLocale "%H:%M" time - AgentLog.update (\s -> s {AgentLog.statusTime = Text.pack timeStr}) - threadDelay 1000000 -- 1s +waitForFile p = do + e <- Directory.doesFileExist p + if e then pure () else threadDelay 100000 >> waitForFile p diff --git a/Omni/Bild/Audit.py b/Omni/Bild/Audit.py new file mode 100755 index 0000000..4df6c0b --- /dev/null +++ b/Omni/Bild/Audit.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Audit codebase builds. + +Iterates through every namespace in the project and runs 'bild'. +For every build failure encountered, it automatically creates a new task. +""" + +# : out bild-audit + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# Extensions supported by bild (from Omni/Bild.hs and Omni/Namespace.hs) +EXTENSIONS = {".c", ".hs", ".lisp", ".nix", ".py", ".scm", ".rs", ".toml"} +MAX_TITLE_LENGTH = 50 + + +def strip_ansi(text: str) -> str: + """Strip ANSI escape codes from text.""" + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + return ansi_escape.sub("", text) + + +def is_ignored(path: Path) -> bool: + """Check if a file is ignored by git.""" + res = subprocess.run( + ["git", "check-ignore", str(path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return res.returncode == 0 + + +def get_buildable_files(root_dir: str = ".") -> list[str]: + """Find all files that bild can build.""" + targets: list[str] = [] + + root = Path(root_dir) + if not root.exists(): + return [] + + for path in root.rglob("*"): + # Skip directories + if path.is_dir(): + continue + + # Skip hidden files/dirs and '_' dirs + parts = path.parts + if any(p.startswith(".") or p == "_" for p in parts): + continue + + if path.suffix in EXTENSIONS: + # Clean up path: keep it relative to cwd if possible + try: + # We want the path as a string, relative to current directory + # if possible + p_str = ( + str(path.relative_to(Path.cwd())) + if path.is_absolute() + else str(path) + ) + except ValueError: + p_str = str(path) + + if not is_ignored(Path(p_str)): + targets.append(p_str) + return targets + + +def run_bild(target: str) -> subprocess.CompletedProcess[str]: + """Run bild on the target.""" + # --time 0 disables timeout + # --loud enables output (which we capture) + cmd = ["bild", "--time", "0", "--loud", target] + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +def create_task( + target: str, + result: subprocess.CompletedProcess[str], + parent_id: str | None = None, +) -> None: + """Create a task for a build failure.""" + # Construct a descriptive title + # Try to get the last meaningful line of error output + lines = (result.stdout + result.stderr).strip().split("\n") + last_line = lines[-1] if lines else "Unknown error" + last_line = strip_ansi(last_line).strip() + + if len(last_line) > MAX_TITLE_LENGTH: + last_line = last_line[: MAX_TITLE_LENGTH - 3] + "..." + + title = f"Build failed: {target} - {last_line}" + + cmd = ["task", "create", title, "--priority", "2", "--json"] + + if parent_id: + cmd.append(f"--discovered-from={parent_id}") + + # Try to infer namespace + # e.g. Omni/Bild.hs -> Omni/Bild + ns = Path(target).parent + if str(ns) != ".": + cmd.append(f"--namespace={ns}") + + print(f"Creating task for {target}...") # noqa: T201 + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + + if proc.returncode != 0: + print(f"Error creating task: {proc.stderr}", file=sys.stderr) # noqa: T201 + else: + # task create --json returns the created task json + print(f"Task created: {proc.stdout.strip()}") # noqa: T201 + + +def main() -> None: + """Run the build audit.""" + parser = argparse.ArgumentParser(description="Audit codebase builds.") + parser.add_argument( + "--parent", + help="Parent task ID to link discovered tasks to", + ) + parser.add_argument( + "paths", + nargs="*", + default=["."], + help="Paths to search for targets", + ) + args = parser.parse_args() + + # Check if bild is available + if not shutil.which("bild"): + print( # noqa: T201 + "Warning: 'bild' command not found. Ensure it is in PATH.", + file=sys.stderr, + ) + + print(f"Scanning for targets in {args.paths}...") # noqa: T201 + targets: list[str] = [] + for path_str in args.paths: + path = Path(path_str) + if path.is_file(): + targets.append(str(path)) + else: + targets.extend(get_buildable_files(path_str)) + + # Remove duplicates + targets = sorted(set(targets)) + print(f"Found {len(targets)} targets.") # noqa: T201 + + failures = 0 + for target in targets: + res = run_bild(target) + + if res.returncode == 0: + print("OK") # noqa: T201 + else: + print("FAIL") # noqa: T201 + failures += 1 + create_task(target, res, args.parent) + + print(f"\nAudit complete. {failures} failures found.") # noqa: T201 + if failures > 0: + sys.exit(1) + else: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/Omni/Task.hs b/Omni/Task.hs index 5008dd2..e1457fb 100644 --- a/Omni/Task.hs +++ b/Omni/Task.hs @@ -205,9 +205,9 @@ move args | args `Cli.has` Cli.command "show" = do tid <- getArgText args "id" tasks <- loadTasks - case filter (\t -> taskId t == tid) tasks of - [] -> putText "Task not found" - (task : _) -> + case findTask tid tasks of + Nothing -> putText "Task not found" + Just task -> if isJsonMode args then outputJson task else showTaskDetailed task @@ -414,7 +414,31 @@ unitTests = Test.unit "namespace normalization handles .hs suffix" <| do let ns = "Omni/Task.hs" validNs = Namespace.fromHaskellModule ns - Namespace.toPath validNs Test.@?= "Omni/Task.hs" + Namespace.toPath validNs Test.@?= "Omni/Task.hs", + Test.unit "generated IDs are lowercase" <| do + task <- createTask "Lowercase check" WorkTask Nothing Nothing P2 [] Nothing + let tid = taskId task + tid Test.@?= T.toLower tid + -- check it matches regex for base36 (t-[0-9a-z]+) + let isLowerBase36 = T.all (\c -> c `elem` ['0' .. '9'] ++ ['a' .. 'z'] || c == 't' || c == '-') tid + isLowerBase36 Test.@?= True, + Test.unit "dependencies are case insensitive" <| do + task1 <- createTask "Blocker" WorkTask Nothing Nothing P2 [] Nothing + let tid1 = taskId task1 + -- Use uppercase ID for dependency + upperTid1 = T.toUpper tid1 + dep = Dependency {depId = upperTid1, depType = Blocks} + task2 <- createTask "Blocked" WorkTask Nothing Nothing P2 [dep] Nothing + + -- task1 is Open, so task2 should NOT be ready + ready <- getReadyTasks + (taskId task2 `notElem` map taskId ready) Test.@?= True + + updateTaskStatus tid1 Done [] + + -- task2 should now be ready because dependency check normalizes IDs + ready2 <- getReadyTasks + (taskId task2 `elem` map taskId ready2) Test.@?= True ] -- | Test CLI argument parsing to ensure docopt string matches actual usage diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index 9e4d2b4..b17c2aa 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -96,12 +96,28 @@ instance FromJSON Task -- | Case-insensitive ID comparison matchesId :: Text -> Text -> Bool -matchesId id1 id2 = T.toLower id1 == T.toLower id2 +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 @@ -188,7 +204,7 @@ 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") @@ -197,7 +213,7 @@ generateChildId :: Text -> IO Text generateChildId parentId = withTaskReadLock <| do tasks <- loadTasksInternal - pure <| computeNextChildId tasks parentId + pure <| computeNextChildId tasks (normalizeId parentId) computeNextChildId :: [Task] -> Text -> Text computeNextChildId tasks parentId = @@ -220,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 @@ -319,7 +335,10 @@ saveTaskInternal task = do createTask :: Text -> TaskType -> Maybe Text -> Maybe Text -> Priority -> [Dependency] -> Maybe Text -> IO Task createTask title taskType parent namespace priority deps description = withTaskWriteLock <| do - tid <- case parent of + let parent' = fmap normalizeId parent + deps' = map normalizeDependency deps + + tid <- case parent' of Nothing -> generateId Just pid -> do tasks <- loadTasksInternal @@ -327,14 +346,14 @@ createTask title taskType parent namespace priority deps description = now <- getCurrentTime let task = Task - { taskId = tid, + { taskId = normalizeId tid, taskTitle = title, taskType = taskType, - taskParent = parent, + taskParent = parent', taskNamespace = namespace, taskStatus = Open, taskPriority = priority, - taskDependencies = deps, + taskDependencies = deps', taskDescription = description, taskCreatedAt = now, taskUpdatedAt = now @@ -415,12 +434,13 @@ getDependencyTree tid = do -- Get task progress getTaskProgress :: Text -> IO TaskProgress -getTaskProgress tid = do +getTaskProgress tidRaw = do + let tid = normalizeId tidRaw tasks <- loadTasks -- Verify task exists (optional, but good for error handling) - case filter (\t -> taskId t == tid) tasks of - [] -> panic "Task not found" - _ -> do + 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 @@ -815,7 +835,7 @@ importTasks filePath = -- Load tasks from import file content <- TIO.readFile filePath let importLines = T.lines content - importedTasks = mapMaybe decodeTask importLines + importedTasks = map normalizeTask (mapMaybe decodeTask importLines) -- Load existing tasks existingTasks <- loadTasksInternal |
