summaryrefslogtreecommitdiff
path: root/Omni
diff options
context:
space:
mode:
Diffstat (limited to 'Omni')
-rw-r--r--Omni/Agent/Git.hs29
-rw-r--r--Omni/Agent/Log.hs102
-rw-r--r--Omni/Agent/Worker.hs64
-rwxr-xr-xOmni/Bild/Audit.py176
-rw-r--r--Omni/Task.hs53
-rw-r--r--Omni/Task/Core.hs62
6 files changed, 368 insertions, 118 deletions
diff --git a/Omni/Agent/Git.hs b/Omni/Agent/Git.hs
index a2009b2..b1978f2 100644
--- a/Omni/Agent/Git.hs
+++ b/Omni/Agent/Git.hs
@@ -25,7 +25,6 @@ import Omni.Test ((@=?))
import qualified Omni.Test as Test
import qualified System.Directory as Directory
import qualified System.Exit as Exit
-import System.FilePath ((</>))
import qualified System.IO.Temp as Temp
import qualified System.Process as Process
@@ -149,30 +148,16 @@ syncWithLive repo = do
Log.info ["git", "syncing with live"]
-- git repo ["fetch", "origin", "live"] -- Optional
- -- Try rebase, if fail, abort
- -- First, proactively cleanup any stale rebase state
- cleanupStaleRebase repo
-
- let cmd = (Process.proc "git" ["rebase", "live"]) {Process.cwd = Just repo}
- (code, _, err) <- Process.readCreateProcessWithExitCode cmd ""
+ -- Try sync (branchless sync), if fail, panic
+ -- This replaces manual rebase and handles stack movement
+ let cmd = (Process.proc "git" ["sync"]) {Process.cwd = Just repo}
+ (code, out, err) <- Process.readCreateProcessWithExitCode cmd ""
case code of
Exit.ExitSuccess -> pure ()
Exit.ExitFailure _ -> do
- Log.warn ["rebase failed, aborting", Text.pack err]
- cleanupStaleRebase repo
- panic "Sync with live failed (rebase conflict)"
-
-cleanupStaleRebase :: FilePath -> IO ()
-cleanupStaleRebase repo = do
- -- Check if a rebase is in progress
- rebaseMerge <- Directory.doesDirectoryExist (repo </> ".git/rebase-merge")
- rebaseApply <- Directory.doesDirectoryExist (repo </> ".git/rebase-apply")
-
- when (rebaseMerge || rebaseApply) <| do
- Log.warn ["git", "detected stale rebase", "aborting"]
- let abort = (Process.proc "git" ["rebase", "--abort"]) {Process.cwd = Just repo}
- _ <- Process.readCreateProcessWithExitCode abort ""
- pure ()
+ Log.warn ["git sync failed", Text.pack err]
+ Log.info [Text.pack out]
+ panic "Sync with live failed (git sync)"
commit :: FilePath -> Text -> IO ()
commit repo msg = do
diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs
index 99b40ae..59efb38 100644
--- a/Omni/Agent/Log.hs
+++ b/Omni/Agent/Log.hs
@@ -6,16 +6,16 @@
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 Data.Aeson (Value(..), decode)
-import qualified Data.Aeson.KeyMap as KM
-import qualified Data.ByteString.Lazy as BL
-import qualified Data.Text.Encoding as TextEnc
-import qualified Data.Vector as V
-- | Parsed log entry
data LogEntry = LogEntry
@@ -32,6 +32,7 @@ data LogEntry = LogEntry
data Status = Status
{ statusWorker :: Text,
statusTask :: Maybe Text,
+ statusThreadId :: Maybe Text,
statusFiles :: Int,
statusCredits :: Double,
statusTime :: Text, -- formatted time string
@@ -44,6 +45,7 @@ emptyStatus workerName =
Status
{ statusWorker = workerName,
statusTask = Nothing,
+ statusThreadId = Nothing,
statusFiles = 0,
statusCredits = 0.0,
statusTime = "00:00",
@@ -60,10 +62,9 @@ init :: Text -> IO ()
init workerName = do
IO.hSetBuffering IO.stderr IO.LineBuffering
writeIORef currentStatus (emptyStatus workerName)
- -- Reserve 2 lines at bottom
- IO.hPutStrLn IO.stderr ""
- IO.hPutStrLn IO.stderr ""
- ANSI.hCursorUp IO.stderr 2
+ -- Reserve 5 lines at bottom
+ replicateM_ 5 (IO.hPutStrLn IO.stderr "")
+ ANSI.hCursorUp IO.stderr 5
-- | Update the status
update :: (Status -> Status) -> IO ()
@@ -79,9 +80,7 @@ updateActivity msg = update (\s -> s {statusActivity = msg})
processLogLine :: Text -> IO ()
processLogLine line = do
let entry = parseLine line
- case entry >>= formatLogEntry of
- Just msg -> updateActivity msg
- Nothing -> pure ()
+ Data.Foldable.for_ (entry +> formatLogEntry) updateActivity
-- | Parse a JSON log line into a LogEntry
parseLine :: Text -> Maybe LogEntry
@@ -109,12 +108,12 @@ parseLine line = do
getBatches o =
case KM.lookup "batches" o of
Just (Array b) ->
- Just <|
- mapMaybe
+ Just
+ <| mapMaybe
( \case
Array b0 ->
- Just <|
- mapMaybe
+ Just
+ <| mapMaybe
( \case
String s -> Just s
_ -> Nothing
@@ -135,37 +134,38 @@ formatLogEntry LogEntry {..} =
((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 "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)
-
+ Just msg
+ | leLevel == Just "error" ->
+ Just ("ERROR: " <> msg)
_ -> Nothing
-- | Log a scrolling message (appears above status bars)
log :: Text -> IO ()
log msg = do
- -- Clear status bars
+ -- Clear status bars (5 lines)
+ ANSI.hClearLine IO.stderr
+ ANSI.hCursorDown IO.stderr 1
ANSI.hClearLine IO.stderr
ANSI.hCursorDown IO.stderr 1
ANSI.hClearLine IO.stderr
- ANSI.hCursorUp IO.stderr 1
+ ANSI.hCursorDown IO.stderr 1
+ ANSI.hClearLine IO.stderr
+ ANSI.hCursorDown IO.stderr 1
+ ANSI.hClearLine IO.stderr
+ ANSI.hCursorUp IO.stderr 4
-- Print message (scrolls screen)
TIO.hPutStrLn IO.stderr msg
@@ -174,37 +174,43 @@ log msg = do
-- (Since we scrolled, we are now on the line above where the first status line should be)
render
--- | Render the two status lines
+-- | Render the 5 status lines (Vertical Layout)
render :: IO ()
render = do
Status {..} <- readIORef currentStatus
- -- Line 1: Meta
- -- [Worker: name] Task: t-123 | Files: 3 | Credits: $0.45 | Time: 05:23
let taskStr = maybe "None" identity statusTask
- meta =
- "[Worker: "
- <> statusWorker
- <> "] Task: "
- <> taskStr
- <> " | Files: "
- <> tshow statusFiles
- <> " | Credits: $"
- <> tshow statusCredits
- <> " | Time: "
- <> statusTime
+ threadStr = maybe "None" identity statusThreadId
+
+ -- Line 1: Worker + Time
+ ANSI.hSetCursorColumn IO.stderr 0
+ ANSI.hClearLine IO.stderr
+ TIO.hPutStr IO.stderr <| "Worker: " <> statusWorker <> " | Time: " <> statusTime
+
+ -- Line 2: Task
+ ANSI.hCursorDown IO.stderr 1
+ ANSI.hSetCursorColumn IO.stderr 0
+ ANSI.hClearLine IO.stderr
+ TIO.hPutStr IO.stderr <| "Task: " <> taskStr
+
+ -- Line 3: Thread
+ ANSI.hCursorDown IO.stderr 1
+ ANSI.hSetCursorColumn IO.stderr 0
+ ANSI.hClearLine IO.stderr
+ TIO.hPutStr IO.stderr <| "Thread: " <> threadStr
+ -- Line 4: Credits
+ ANSI.hCursorDown IO.stderr 1
ANSI.hSetCursorColumn IO.stderr 0
ANSI.hClearLine IO.stderr
- TIO.hPutStr IO.stderr meta
+ TIO.hPutStr IO.stderr <| "Credits: $" <> tshow statusCredits
- -- Line 2: Activity
- -- [14:05:22] > Thinking...
+ -- Line 5: Activity
ANSI.hCursorDown IO.stderr 1
ANSI.hSetCursorColumn IO.stderr 0
ANSI.hClearLine IO.stderr
TIO.hPutStr IO.stderr ("> " <> statusActivity)
- -- Return cursor to line 1
- ANSI.hCursorUp IO.stderr 1
+ -- Return cursor to Line 1
+ ANSI.hCursorUp IO.stderr 4
IO.hFlush IO.stderr
diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs
index 3bf4579..c01a853 100644
--- a/Omni/Agent/Worker.hs
+++ b/Omni/Agent/Worker.hs
@@ -5,7 +5,15 @@
module Omni.Agent.Worker where
import Alpha
+import Control.Concurrent (forkIO, killThread, threadDelay)
+import Control.Monad (forever)
+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.Text.IO as TIO
+import qualified Data.Time as Time
import qualified Omni.Agent.Core as Core
import qualified Omni.Agent.Git as Git
import qualified Omni.Agent.Log as AgentLog
@@ -15,9 +23,6 @@ import qualified System.Exit as Exit
import System.FilePath ((</>))
import qualified System.IO as IO
import qualified System.Process as Process
-import Control.Concurrent (forkIO, killThread, threadDelay)
-import qualified Data.Text.IO as TIO
-import Control.Monad (forever)
start :: Core.Worker -> IO ()
start worker = do
@@ -62,7 +67,7 @@ processTask worker task = do
AgentLog.updateActivity ("Claiming task " <> tid)
-- Claim task
- TaskCore.updateTaskStatus tid TaskCore.InProgress
+ TaskCore.updateTaskStatus tid TaskCore.InProgress []
-- Commit claim locally
Git.commit repo ("task: claim " <> tid)
@@ -91,18 +96,20 @@ 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
AgentLog.log "Agent finished successfully"
-- Update status to Review (bundled with feature commit)
- TaskCore.updateTaskStatus tid TaskCore.Review
+ 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..."
@@ -115,18 +122,17 @@ processTask worker task = do
Git.syncWithLive repo
-- Update status to Review (for signaling)
- TaskCore.updateTaskStatus tid TaskCore.Review
+ TaskCore.updateTaskStatus tid TaskCore.Review []
Git.commit repo ("task: review " <> tid)
-
+
AgentLog.log ("[✓] Task " <> tid <> " completed")
AgentLog.update (\s -> s {AgentLog.statusTask = Nothing})
-
Exit.ExitFailure code -> do
AgentLog.log ("Agent failed with code " <> tshow code)
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"
@@ -138,7 +144,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,21 +156,36 @@ runAmp repo task = do
Directory.createDirectoryIfMissing True (repo </> "_/llm")
let logPath = repo </> "_/llm/amp.log"
+
-- Ensure log file is empty/exists
IO.writeFile logPath ""
+ -- 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
- monitorThread <- forkIO (monitorLog logPath)
+ 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 tidLog
- killThread monitorThread
- pure exitCode
+ pure (exitCode, Text.pack out)
formatTask :: TaskCore.Task -> Text
formatTask t =
@@ -221,9 +243,9 @@ monitorLog path = do
-- Wait for file to exist
waitForFile path
- IO.withFile path IO.ReadMode $ \h -> do
+ IO.withFile path IO.ReadMode <| \h -> do
IO.hSetBuffering h IO.LineBuffering
- forever $ do
+ forever <| do
eof <- IO.hIsEOF h
if eof
then threadDelay 100000 -- 0.1s
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 36b318b..e1457fb 100644
--- a/Omni/Task.hs
+++ b/Omni/Task.hs
@@ -44,7 +44,7 @@ Usage:
task list [options]
task ready [--json]
task show <id> [--json]
- task update <id> <status> [--json]
+ task update <id> <status> [options]
task deps <id> [--json]
task tree [<id>] [--json]
task progress <id> [--json]
@@ -205,22 +205,39 @@ 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
| args `Cli.has` Cli.command "update" = do
tid <- getArgText args "id"
statusStr <- getArgText args "status"
+
+ -- Handle update dependencies
+ deps <- do
+ -- Parse --deps and --dep-type
+ ids <- case Cli.getArg args (Cli.longOption "deps") of
+ Nothing -> pure []
+ Just depStr -> pure <| T.splitOn "," (T.pack depStr)
+ dtype <- case Cli.getArg args (Cli.longOption "dep-type") of
+ Nothing -> pure Blocks
+ Just "blocks" -> pure Blocks
+ Just "discovered-from" -> pure DiscoveredFrom
+ Just "parent-child" -> pure ParentChild
+ Just "related" -> pure Related
+ Just other -> panic <| "Invalid dependency type: " <> T.pack other <> ". Use: blocks, discovered-from, parent-child, or related"
+ pure (map (\d -> Dependency {depId = d, depType = dtype}) ids)
+
let newStatus = case statusStr of
"open" -> Open
"in-progress" -> InProgress
"review" -> Review
"done" -> Done
_ -> panic "Invalid status. Use: open, in-progress, review, or done"
- updateTaskStatus tid newStatus
+
+ updateTaskStatus tid newStatus deps
if isJsonMode args
then outputSuccess <| "Updated task " <> tid
else do
@@ -397,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 bab1912..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
@@ -343,15 +362,15 @@ createTask title taskType parent namespace priority deps description =
pure task
-- Update task status
-updateTaskStatus :: Text -> Status -> IO ()
-updateTaskStatus tid newStatus =
+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}
+ 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
@@ -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