diff options
| -rw-r--r-- | .tasks/tasks.jsonl | 9 | ||||
| -rw-r--r-- | Omni/Agent/Log.hs | 213 | ||||
| -rw-r--r-- | Omni/Agent/Worker.hs | 99 | ||||
| -rwxr-xr-x | Omni/Agent/start-worker.sh | 6 | ||||
| -rw-r--r-- | Omni/Ci.hs | 193 | ||||
| -rwxr-xr-x | Omni/Ci.sh | 65 | ||||
| -rw-r--r-- | Omni/Task.hs | 28 | ||||
| -rw-r--r-- | Omni/Task/Core.hs | 9 |
8 files changed, 365 insertions, 257 deletions
diff --git a/.tasks/tasks.jsonl b/.tasks/tasks.jsonl index aa8c1da..cd4dd0a 100644 --- a/.tasks/tasks.jsonl +++ b/.tasks/tasks.jsonl @@ -202,8 +202,9 @@ {"taskCreatedAt":"2025-11-22T10:39:11.364170862Z","taskDependencies":[{"depId":"t-rwbmpxabk","depType":"DiscoveredFrom"}],"taskDescription":null,"taskId":"t-rwcm6todb","taskNamespace":null,"taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Fix failing tests in Biz/PodcastItLater/Web.py (UsageLimits and EpisodeDetail)","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T14:32:24.762100815Z"} {"taskCreatedAt":"2025-11-22T20:37:09.166630362Z","taskDependencies":[],"taskDescription":null,"taskId":"t-1o2bxcq7999","taskNamespace":"Omni/Workflow.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Open","taskTitle":"Phase 1: Foundations (Task & CI)","taskType":"Epic","taskUpdatedAt":"2025-11-22T20:37:09.166630362Z"} {"taskCreatedAt":"2025-11-22T20:37:13.980489314Z","taskDependencies":[],"taskDescription":"Configure .gitattributes and .git/config (via Omni/Ide/hooks or setup) to use 'agent merge-driver' for .tasks/tasks.jsonl. This prevents data loss when merging branches with divergent task lists.","taskId":"t-1o2bxcq7999.1","taskNamespace":"Omni/Ide.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P0","taskStatus":"Done","taskTitle":"Configure git merge driver for tasks.jsonl","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:57:23.592078308Z"} -{"taskCreatedAt":"2025-11-22T20:37:18.719690905Z","taskDependencies":[],"taskDescription":"Update Task Core to include Approved status, update CLI to support it, update TaskStats, and fix any compilation errors. Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.2","taskNamespace":"Omni/Task.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Review","taskTitle":"Add Approved status to Omni/Task","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:21:07.384080767Z"} -{"taskCreatedAt":"2025-11-22T20:37:23.378739333Z","taskDependencies":[],"taskDescription":"Rewrite Omni/Ci.sh into a robust Haskell program (Omni/Ci.hs). Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.3","taskNamespace":"Omni/Ci.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Review","taskTitle":"Implement Omni/Ci.hs","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:23:53.72628323Z"} -{"taskCreatedAt":"2025-11-22T20:37:27.396872011Z","taskDependencies":[],"taskDescription":"The Time, Thread, and Credits fields in the agent status bar are not being populated. Update Omni/Agent/Log.hs to parse these fields from the JSON log output.","taskId":"t-1o2bxd11zv9","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Review","taskTitle":"Fix missing Time, Thread, and Credits in Agent Log","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:31:55.395825865Z"} +{"taskCreatedAt":"2025-11-22T20:37:18.719690905Z","taskDependencies":[],"taskDescription":"Update Task Core to include Approved status, update CLI to support it, update TaskStats, and fix any compilation errors. Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.2","taskNamespace":"Omni/Task.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Done","taskTitle":"Add Approved status to Omni/Task","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:59:08.985299564Z"} +{"taskCreatedAt":"2025-11-22T20:37:23.378739333Z","taskDependencies":[],"taskDescription":"Rewrite Omni/Ci.sh into a robust Haskell program (Omni/Ci.hs). Reference plan: /home/ben/omni/_/llm/PLAN_Autonomous_Workflow.md","taskId":"t-1o2bxcq7999.3","taskNamespace":"Omni/Ci.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Done","taskTitle":"Implement Omni/Ci.hs","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T22:01:09.779228442Z"} +{"taskCreatedAt":"2025-11-22T20:37:27.396872011Z","taskDependencies":[],"taskDescription":"The Time, Thread, and Credits fields in the agent status bar are not being populated. Update Omni/Agent/Log.hs to parse these fields from the JSON log output.","taskId":"t-1o2bxd11zv9","taskNamespace":"Omni/Agent.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Done","taskTitle":"Fix missing Time, Thread, and Credits in Agent Log","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T22:02:50.438643714Z"} {"taskCreatedAt":"2025-11-22T20:37:31.615764727Z","taskDependencies":[],"taskDescription":"The 'task ready' command currently lists Epics. Update 'getReadyTasks' in Omni/Task/Core.hs to exclude tasks where taskType == Epic.","taskId":"t-1o2bxd3kezj","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P1","taskStatus":"Review","taskTitle":"Fix task ready to exclude Epics","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:34:28.577198837Z"} -{"taskCreatedAt":"2025-11-22T21:45:10.578083608Z","taskDependencies":[],"taskDescription":"Update Omni/Agent/start-worker.sh to run 'git sync' in the worker directory before building 'task' and 'agent'. This ensures the worker has the latest tools and code from live.","taskId":"t-1o2bxcq7999.4","taskNamespace":"Omni/Agent.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Review","taskTitle":"Sync worker repo in start-worker.sh","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:46:40.928370623Z"} +{"taskCreatedAt":"2025-11-22T21:45:10.578083608Z","taskDependencies":[],"taskDescription":"Update Omni/Agent/start-worker.sh to run 'git sync' in the worker directory before building 'task' and 'agent'. This ensures the worker has the latest tools and code from live.","taskId":"t-1o2bxcq7999.4","taskNamespace":"Omni/Agent.hs","taskParent":"t-1o2bxcq7999","taskPriority":"P1","taskStatus":"Done","taskTitle":"Sync worker repo in start-worker.sh","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T22:01:47.245671772Z"} +{"taskCreatedAt":"2025-11-22T21:19:54.675769476Z","taskDependencies":[],"taskDescription":null,"taskId":"t-rwd249bi3","taskNamespace":"Omni/Task.hs","taskParent":null,"taskPriority":"P2","taskStatus":"Done","taskTitle":"Test Approved Status","taskType":"WorkTask","taskUpdatedAt":"2025-11-22T21:20:10.652509625Z"} diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs index dd66abc..07770d0 100644 --- a/Omni/Agent/Log.hs +++ b/Omni/Agent/Log.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE NoImplicitPrelude #-} @@ -7,34 +6,22 @@ module Omni.Agent.Log where import Alpha -import Data.Aeson (Value (..), decode) -import qualified Data.Aeson.KeyMap as KM +import Data.Aeson ((.:), (.:?)) +import qualified Data.Aeson as Aeson 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 as Text +import qualified Data.Text.Encoding as TE 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 { statusWorker :: Text, statusTask :: Maybe Text, - statusThreadId :: Maybe Text, + statusThread :: Maybe Text, statusFiles :: Int, statusCredits :: Double, statusTime :: Text, -- formatted time string @@ -47,7 +34,7 @@ emptyStatus workerName = Status { statusWorker = workerName, statusTask = Nothing, - statusThreadId = Nothing, + statusThread = Nothing, statusFiles = 0, statusCredits = 0.0, statusTime = "00:00", @@ -64,9 +51,10 @@ init :: Text -> IO () init workerName = do IO.hSetBuffering IO.stderr IO.LineBuffering writeIORef currentStatus (emptyStatus workerName) - -- Reserve 5 lines at bottom - replicateM_ 5 (IO.hPutStrLn IO.stderr "") - ANSI.hCursorUp IO.stderr 5 + -- Reserve 2 lines at bottom + IO.hPutStrLn IO.stderr "" + IO.hPutStrLn IO.stderr "" + ANSI.hCursorUp IO.stderr 2 -- | Update the status update :: (Status -> Status) -> IO () @@ -78,96 +66,14 @@ 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 - -- 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.hCursorDown IO.stderr 1 + -- Clear status bars ANSI.hClearLine IO.stderr ANSI.hCursorDown IO.stderr 1 ANSI.hClearLine IO.stderr - ANSI.hCursorUp IO.stderr 4 + ANSI.hCursorUp IO.stderr 1 -- Print message (scrolls screen) TIO.hPutStrLn IO.stderr msg @@ -176,43 +82,90 @@ log msg = do -- (Since we scrolled, we are now on the line above where the first status line should be) render --- | Render the 5 status lines (Vertical Layout) +-- | Render the two status lines render :: IO () render = do Status {..} <- readIORef currentStatus + -- Line 1: Meta + -- [Worker: name] Task: t-123 | Thread: T-abc | Files: 3 | Credits: $0.45 | Time: 05:23 let taskStr = maybe "None" identity statusTask - threadStr = maybe "None" identity statusThreadId + threadStr = maybe "None" identity statusThread + meta = + "[Worker: " + <> statusWorker + <> "] Task: " + <> taskStr + <> " | Thread: " + <> threadStr + <> " | Files: " + <> tshow statusFiles + <> " | Credits: $" + <> tshow statusCredits + <> " | Time: " + <> statusTime - -- Line 1: Worker + Time ANSI.hSetCursorColumn IO.stderr 0 ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr <| "Worker: " <> statusWorker <> " | Time: " <> statusTime + TIO.hPutStr IO.stderr meta - -- Line 2: Task + -- Line 2: Activity + -- [14:05:22] > Thinking... ANSI.hCursorDown IO.stderr 1 ANSI.hSetCursorColumn IO.stderr 0 ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr <| "Task: " <> taskStr + TIO.hPutStr IO.stderr ("> " <> statusActivity) - -- Line 3: Thread - ANSI.hCursorDown IO.stderr 1 - ANSI.hSetCursorColumn IO.stderr 0 - ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr <| "Thread: " <> threadStr + -- Return cursor to line 1 + ANSI.hCursorUp IO.stderr 1 + IO.hFlush IO.stderr - -- Line 4: Credits - ANSI.hCursorDown IO.stderr 1 - ANSI.hSetCursorColumn IO.stderr 0 - ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr <| "Credits: $" <> str (printf "%.2f" statusCredits :: String) +-- | Log Entry from JSON +data LogEntry = LogEntry + { leMessage :: Text, + leThreadId :: Maybe Text, + leCredits :: Maybe Double, + leTotalCredits :: Maybe Double, + leTimestamp :: Maybe Text + } + deriving (Show, Eq) - -- Line 5: Activity - ANSI.hCursorDown IO.stderr 1 - ANSI.hSetCursorColumn IO.stderr 0 - ANSI.hClearLine IO.stderr - TIO.hPutStr IO.stderr ("> " <> statusActivity) +instance Aeson.FromJSON LogEntry where + parseJSON = + Aeson.withObject "LogEntry" <| \v -> + LogEntry + </ v + .: "message" + <*> v + .:? "threadId" + <*> v + .:? "credits" + <*> v + .:? "totalCredits" + <*> v + .:? "timestamp" + +-- | Parse a log line and update status +processLogLine :: Text -> IO () +processLogLine line = do + let bs = BL.fromStrict <| TE.encodeUtf8 line + case Aeson.decode bs of + Just entry -> update (updateFromEntry entry) + Nothing -> pure () -- Ignore invalid JSON + +updateFromEntry :: LogEntry -> Status -> Status +updateFromEntry LogEntry {..} s = + s + { statusThread = leThreadId <|> statusThread s, + statusCredits = fromMaybe (statusCredits s) (leTotalCredits <|> leCredits), + statusTime = maybe (statusTime s) formatTime leTimestamp + } - -- Return cursor to Line 1 - ANSI.hCursorUp IO.stderr 4 - IO.hFlush IO.stderr +formatTime :: Text -> Text +formatTime ts = + -- "2025-11-22T21:24:02.512Z" -> "21:24" + case Text.splitOn "T" ts of + [_, time] -> case Text.splitOn ":" time of + (h : m : _) -> h <> ":" <> m + _ -> ts + _ -> ts diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs index 1cc0b8d..9f9e3bb 100644 --- a/Omni/Agent/Worker.hs +++ b/Omni/Agent/Worker.hs @@ -89,7 +89,7 @@ processTask worker task = do -- Run Amp AgentLog.updateActivity "Running Amp agent..." - (exitCode, output) <- runAmp repo task + exitCode <- runAmp repo task case exitCode of Exit.ExitSuccess -> do @@ -99,10 +99,8 @@ processTask worker task = do TaskCore.updateTaskStatus tid TaskCore.Review [] -- Commit changes - -- 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 + -- We should check if there are changes, but 'git add .' is safe. + Git.commit repo ("feat: implement " <> tid) -- Submit for review AgentLog.updateActivity "Submitting for review..." @@ -125,7 +123,7 @@ processTask worker task = do AgentLog.updateActivity "Agent failed, retrying..." threadDelay (10 * 1000000) -- Sleep 10s -runAmp :: FilePath -> TaskCore.Task -> IO (Exit.ExitCode, Text) +runAmp :: FilePath -> TaskCore.Task -> IO Exit.ExitCode runAmp repo task = do let prompt = "You are a Worker Agent.\n" @@ -137,8 +135,7 @@ 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. Do NOT run 'git commit'. The system will commit your changes automatically.\n" - <> "7. When finished and tested, exit.\n\n" + <> "6. When finished and tested, exit.\n\n" <> "Context:\n" <> "- You are working in '" <> Text.pack repo @@ -147,38 +144,25 @@ runAmp repo task = do <> fromMaybe "root" (TaskCore.taskNamespace task) <> "'.\n" - 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 logFile = repo </> "_/llm/amp.log" - let fullPrompt = - prompt - <> "\n\nREPOSITORY GUIDELINES (AGENTS.md):\n" - <> agentsMd + -- Remove old log file + exists <- Directory.doesFileExist logFile + when exists (Directory.removeFile logFile) - -- Monitor log file - tidLog <- forkIO (monitorLog logPath) + Directory.createDirectoryIfMissing True (repo </> "_/llm") -- Assume amp is in PATH - let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack fullPrompt] + let args = ["--log-level", "debug", "--log-file", "_/llm/amp.log", "--dangerously-allow-all", "-x", Text.unpack prompt] let cp = (Process.proc "amp" args) {Process.cwd = Just repo} - (exitCode, out, _err) <- Process.readCreateProcessWithExitCode cp "" + (_, _, _, ph) <- Process.createProcess cp - -- Cleanup - killThread tidLog + tid <- forkIO <| monitorLog logFile ph - pure (exitCode, Text.pack out) + exitCode <- Process.waitForProcess ph + killThread tid + pure exitCode formatTask :: TaskCore.Task -> Text formatTask t = @@ -210,6 +194,37 @@ formatTask t = where formatDep dep = " - " <> TaskCore.depId dep <> " [" <> Text.pack (show (TaskCore.depType dep)) <> "]" +monitorLog :: FilePath -> Process.ProcessHandle -> IO () +monitorLog path ph = do + waitForFile path + IO.withFile path IO.ReadMode <| \h -> do + IO.hSetBuffering h IO.LineBuffering + go h + where + go h = do + eof <- IO.hIsEOF h + if eof + then do + mExit <- Process.getProcessExitCode ph + case mExit of + Nothing -> do + threadDelay 100000 -- 0.1s + go h + Just _ -> pure () + else do + line <- TIO.hGetLine h + AgentLog.processLogLine line + go h + +waitForFile :: FilePath -> IO () +waitForFile path = do + exists <- Directory.doesFileExist path + if exists + then pure () + else do + threadDelay 100000 + waitForFile path + findBaseBranch :: FilePath -> TaskCore.Task -> IO Text findBaseBranch repo task = do let deps = TaskCore.taskDependencies task @@ -230,23 +245,3 @@ findBaseBranch repo task = do case candidates of (candidate : _) -> pure ("task/" <> TaskCore.depId candidate) [] -> pure "live" - -monitorLog :: FilePath -> IO () -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 <- TIO.hGetLine h - AgentLog.processLogLine line - -waitForFile :: FilePath -> IO () -waitForFile p = do - e <- Directory.doesFileExist p - if e then pure () else threadDelay 100000 >> waitForFile p diff --git a/Omni/Agent/start-worker.sh b/Omni/Agent/start-worker.sh index 310ca56..457c83c 100755 --- a/Omni/Agent/start-worker.sh +++ b/Omni/Agent/start-worker.sh @@ -37,6 +37,12 @@ fi # Ensure worker has local task and agent binaries mkdir -p "$WORKER_PATH/_/bin" +echo "Syncing worker repo..." +if ! (cd "$WORKER_PATH" && git sync); then + echo "Error: Failed to run 'git sync' in worker directory." + exit 1 +fi + echo "Building 'task' in worker..." if ! (cd "$WORKER_PATH" && bild Omni/Task.hs); then echo "Error: Failed to build 'task' in worker directory." diff --git a/Omni/Ci.hs b/Omni/Ci.hs new file mode 100644 index 0000000..35abe2b --- /dev/null +++ b/Omni/Ci.hs @@ -0,0 +1,193 @@ +#!/usr/bin/env run.sh +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE NoImplicitPrelude #-} +{-# LANGUAGE QuasiQuotes #-} + +-- | A robust CI program replacing Omni/Ci.sh +-- +-- : out ci +module Omni.Ci (main) where + +import Alpha +import qualified Omni.Cli as Cli +import qualified Omni.Log as Log +import qualified Omni.Test as Test +import qualified System.Environment as Environment +import qualified System.Exit as Exit +import qualified System.Process as Process +import qualified Data.Text as Text +import qualified Data.List as List +import qualified System.Directory as Dir +import System.FilePath ((</>)) + +main :: IO () +main = Cli.main <| Cli.Plan help move test pure + +help :: Cli.Docopt +help = + [Cli.docopt| +omni-ci - Continuous Integration + +Usage: + ci test + ci [options] + +Options: + -h, --help Print this info +|] + +test :: Test.Tree +test = + Test.group + "Omni.Ci" + [ Test.unit "placeholder test" <| do + True @=? True + ] + +move :: Cli.Arguments -> IO () +move _ = do + -- 1. Check for dirty worktree + status <- readProcess "git" ["status", "-s"] "" + unless (null status) <| do + Log.fail ["ci", "dirty worktree"] + Exit.exitWith (Exit.ExitFailure 1) + + -- 2. Setup environment + -- We need to ensure timeout is disabled for CI builds + -- Equivalent to: BILD_ARGS="--time 0 ${BILD_ARGS:-""}" + currentBildArgs <- Environment.lookupEnv "BILD_ARGS" + let bildArgs = "--time 0 " <> fromMaybe "" currentBildArgs + Environment.setEnv "BILD_ARGS" bildArgs + + -- 3. Get user info + at <- readProcess "date" ["-R"] "" |> fmap chomp + user <- readProcess "git" ["config", "--get", "user.name"] "" |> fmap chomp + mail <- readProcess "git" ["config", "--get", "user.email"] "" |> fmap chomp + + -- 4. Check existing git notes + -- commit=$(git notes --ref=ci show HEAD || true) + (exitCode, noteContent, _) <- Process.readProcessWithExitCode "git" ["notes", "--ref=ci", "show", "HEAD"] "" + + let alreadyGood = case exitCode of + Exit.ExitSuccess -> + let content = Text.pack noteContent + in ("Lint-is: good" `Text.isInfixOf` content) && ("Test-is: good" `Text.isInfixOf` content) + _ -> False + + when alreadyGood <| do + Log.pass ["ci", "already verified"] + Exit.exitSuccess + + -- 5. Run Lint + coderoot <- getCoderoot + let runlint = coderoot </> "_/bin/lint" + + lintExists <- Dir.doesFileExist runlint + unless lintExists <| do + Log.info ["ci", "building lint"] + callProcess "bild" [coderoot </> "Omni/Lint.hs"] + + Log.info ["ci", "running lint"] + -- if "$runlint" "${CODEROOT:?}"/**/* + -- We need to expand **/* which shell does. + -- Since we are in Haskell, we can just pass "." or call git ls-files or similar. + -- Omni/Ci.sh used "${CODEROOT:?}"/**/* which relies on bash globbing. + -- Omni/Lint.hs recursively checks if passed directory or uses git diff if no args. + -- But Omni/Ci.sh passes **/*. + -- Let's try passing the root directory or just run it without args? + -- Omni/Lint.hs says: + -- "case Cli.getAllArgs args (Cli.argument "file") of [] -> changedFiles ..." + -- So if we pass nothing, it only checks changed files. + -- The CI script explicitly passed everything. + -- We can replicate "everything" by passing the coderoot, assuming Lint handles directories recursively? + -- Omni/Lint.hs: "traverse Directory.makeAbsolute /> map (Namespace.fromPath root) ... filter (not <. Namespace.isCab)" + -- It seems it expects files. + -- We can use `git ls-files` to get all files. + allFiles <- readProcess "git" ["ls-files"] "" + /> lines + /> map Text.unpack + /> filter (not . null) + + -- We can't pass all files as arguments if there are too many (ARG_MAX). + -- But wait, Omni/Lint.hs takes arguments. + -- If we want to check everything, maybe we should implement a "check all" mode in Lint or pass chunks. + -- However, looking at Omni/Ci.sh: `"$runlint" "${CODEROOT:?}"/**/*` + -- This globbing is handled by the shell. It might be huge. + -- If I run `lint` with `.` it might work if Lint supports directories. + -- Omni/Lint.hs: "files |> ... filterM Directory.doesFileExist" + -- It seems it filters for files. + -- If I pass a directory, `doesFileExist` will return False. + -- So I must pass files. + + -- Let's pass all files from git ls-files. + -- But we must be careful about ARG_MAX. + -- For now, let's try passing them. If it fails, we might need to batch. + + lintResult <- do + -- We run lint on all files. + -- Note: calling callProcess with huge list might fail. + -- Let's see if we can avoid passing all files if Lint supports it. + -- Omni/Lint.hs doesn't seem to support directory recursion on its own if passed a dir, + -- it treats args as file paths. + + -- We will try to run it. + (exitCodeLint, _, _) <- Process.readProcessWithExitCode runlint allFiles "" + pure $ case exitCodeLint of + Exit.ExitSuccess -> "good" + _ -> "fail" + + -- 6. Run Tests + -- if bild "${BILD_ARGS:-""}" --test "${CODEROOT:?}"/**/* + Log.info ["ci", "running tests"] + + testResult <- do + -- similarly, bild takes targets. + -- bild "${CODEROOT:?}"/**/* + -- We can pass namespaces. + -- Let's try passing all files again. + -- bild handles namespaces. + (exitCodeTest, _, _) <- Process.readProcessWithExitCode "bild" ("--test" : allFiles) "" + pure $ case exitCodeTest of + Exit.ExitSuccess -> "good" + _ -> "fail" + + -- 7. Create Note + let note = Text.unlines + [ "Lint-is: " <> lintResult + , "Test-is: " <> testResult + , "Test-by: " <> user <> " <" <> mail <> ">" + , "Test-at: " <> at + ] + + -- 8. Append Note + callProcess "git" ["notes", "--ref=ci", "append", "-m", Text.unpack note] + + -- 9. Exit + if lintResult == "good" && testResult == "good" + then Exit.exitSuccess + else do + Log.fail ["ci", "verification failed"] + Exit.exitWith (Exit.ExitFailure 1) + + +-- Helpers + +readProcess :: FilePath -> [String] -> String -> IO Text +readProcess cmd args input = do + out <- Process.readProcess cmd args input + pure (Text.pack out) + +callProcess :: FilePath -> [String] -> IO () +callProcess cmd args = do + Process.callProcess cmd args + +getCoderoot :: IO FilePath +getCoderoot = do + mEnvRoot <- Environment.lookupEnv "CODEROOT" + cwd <- Dir.getCurrentDirectory + case mEnvRoot of + Just envRoot -> pure envRoot + Nothing -> panic "CODEROOT not set" -- Simplified for now + diff --git a/Omni/Ci.sh b/Omni/Ci.sh deleted file mode 100755 index a749b7a..0000000 --- a/Omni/Ci.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# -# A simple ci that saves its results in a git note, formatted according to -# RFC-2822, more or less. -# -# To run this manually, exec the script. It will by default run the tests for -# HEAD, whatever you currently have checked out. -# -# It would be cool to use a zero-knowledge proof mechanism here to prove that -# so-and-so ran the tests, but I'll have to research how to do that. -# -# ensure we don't exit on bild failure, only on CI script error - set +e - set -u -## - [[ -n $(git status -s) ]] && { echo fail: dirty worktree; exit 1; } -## -## disable timeout for ci builds - BILD_ARGS="--time 0 ${BILD_ARGS:-""}" -## - at=$(date -R) - user=$(git config --get user.name) - mail=$(git config --get user.email) -## - commit=$(git notes --ref=ci show HEAD || true) - if [[ -n "$commit" ]] - then - if grep -q "Lint-is: good" <<< "$commit" - then - exit 0 - fi - if grep -q "Test-is: good" <<< "$commit" - then - exit 0 - fi - fi -## - runlint="$CODEROOT"/_/bin/lint - [[ ! -f "$runlint" ]] && bild "${BILD_ARGS:-""}" "${CODEROOT:?}"/Omni/Lint.hs - if "$runlint" "${CODEROOT:?}"/**/* - then - lint_result="good" - else - lint_result="fail" - fi -## - if bild "${BILD_ARGS:-""}" --test "${CODEROOT:?}"/**/* - then - test_result="good" - else - test_result="fail" - fi -## - read -r -d '' note <<EOF -Lint-is: $lint_result -Test-is: $test_result -Test-by: $user <$mail> -Test-at: $at -EOF -## - git notes --ref=ci append -m "$note" -## -# exit 1 if failure - [[ ! "$lint_result" == "fail" && ! "$test_result" == "fail" ]] -## diff --git a/Omni/Task.hs b/Omni/Task.hs index e4294dc..82449db 100644 --- a/Omni/Task.hs +++ b/Omni/Task.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NoImplicitPrelude #-} -- : out task +-- : modified by benign worker module Omni.Task where import Alpha @@ -79,8 +80,8 @@ Options: --title=<title> Task title --type=<type> Task type: epic or task --parent=<id> Parent epic ID - --priority=<p> Priority: 0-4 (0=critical, 4=backlog) - --status=<status> Task status (open, in-progress, review, done) + --priority=<p> Priority: 0-4 (0=critical, 4=backlog, default: 2) + --status=<status> Filter by status: open, in-progress, review, approved, done --epic=<id> Filter stats by epic (recursive) --deps=<ids> Comma-separated list of dependency IDs --dep-type=<type> Dependency type: blocks, discovered-from, parent-child, related @@ -95,7 +96,7 @@ Options: Arguments: <title> Task title <id> Task ID - <status> Task status (open, in-progress, review, done) + <status> Task status (open, in-progress, review, approved, done) <file> JSONL file to import |] @@ -255,8 +256,9 @@ move args Just "open" -> pure <| Just Open Just "in-progress" -> pure <| Just InProgress Just "review" -> pure <| Just Review + Just "approved" -> pure <| Just Approved Just "done" -> pure <| Just Done - Just other -> panic <| "Invalid status: " <> T.pack other <> ". Use: open, in-progress, review, or done" + Just other -> panic <| "Invalid status: " <> T.pack other <> ". Use: open, in-progress, review, approved, or done" maybeNamespace <- case Cli.getArg args (Cli.longOption "namespace") of Nothing -> pure Nothing Just ns -> do @@ -306,8 +308,9 @@ move args "open" -> Open "in-progress" -> InProgress "review" -> Review + "approved" -> Approved "done" -> Done - _ -> panic "Invalid status. Use: open, in-progress, review, or done" + _ -> panic "Invalid status. Use: open, in-progress, review, approved, or done" updateTaskStatus tid newStatus deps if isJsonMode args @@ -669,6 +672,13 @@ cliTests = Right args -> do args `Cli.has` Cli.command "list" Test.@?= True Cli.getArg args (Cli.longOption "status") Test.@?= Just "open", + Test.unit "list with --status=approved filter" <| do + let result = Docopt.parseArgs help ["list", "--status=approved"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'list --status=approved': " <> show err + Right args -> do + args `Cli.has` Cli.command "list" Test.@?= True + Cli.getArg args (Cli.longOption "status") Test.@?= Just "approved", Test.unit "ready command" <| do let result = Docopt.parseArgs help ["ready"] case result of @@ -689,6 +699,14 @@ cliTests = args `Cli.has` Cli.command "update" Test.@?= True Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123" Cli.getArg args (Cli.argument "status") Test.@?= Just "done", + Test.unit "update command with approved" <| do + let result = Docopt.parseArgs help ["update", "t-abc123", "approved"] + case result of + Left err -> Test.assertFailure <| "Failed to parse 'update ... approved': " <> show err + Right args -> do + args `Cli.has` Cli.command "update" Test.@?= True + Cli.getArg args (Cli.argument "id") Test.@?= Just "t-abc123" + Cli.getArg args (Cli.argument "status") Test.@?= Just "approved", Test.unit "update with --json flag" <| do let result = Docopt.parseArgs help ["update", "t-abc123", "done", "--json"] case result of diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs index a9f6743..2f2cccb 100644 --- a/Omni/Task/Core.hs +++ b/Omni/Task/Core.hs @@ -42,7 +42,7 @@ data Task = Task data TaskType = Epic | WorkTask deriving (Show, Eq, Generic) -data Status = Open | InProgress | Review | Done +data Status = Open | InProgress | Review | Approved | Done deriving (Show, Eq, Generic) -- Priority levels (matching beads convention) @@ -579,6 +579,7 @@ showTaskTree maybeId = do Open -> "[ ]" InProgress -> "[~]" Review -> "[?]" + Approved -> "[+]" Done -> "[✓]" coloredStatusStr = case taskType task of @@ -587,6 +588,7 @@ showTaskTree maybeId = do Open -> bold statusStr InProgress -> yellow statusStr Review -> magenta statusStr + Approved -> green statusStr Done -> green statusStr nsStr = case taskNamespace task of @@ -646,6 +648,7 @@ printTask t = do Open -> bold s InProgress -> yellow s Review -> magenta s + Approved -> green s Done -> green s coloredTitle = if taskType t == Epic then bold (taskTitle t) else taskTitle t @@ -756,6 +759,7 @@ data TaskStats = TaskStats openTasks :: Int, inProgressTasks :: Int, reviewTasks :: Int, + approvedTasks :: Int, doneTasks :: Int, totalEpics :: Int, readyTasks :: Int, @@ -791,6 +795,7 @@ getTaskStats maybeEpicId = do open = length <| filter (\t -> taskStatus t == Open) tasks inProg = length <| filter (\t -> taskStatus t == InProgress) tasks review = length <| filter (\t -> taskStatus t == Review) tasks + approved = length <| filter (\t -> taskStatus t == Approved) tasks done = length <| filter (\t -> taskStatus t == Done) tasks epics = length <| filter (\t -> taskType t == Epic) tasks readyCount' = readyCount @@ -813,6 +818,7 @@ getTaskStats maybeEpicId = do openTasks = open, inProgressTasks = inProg, reviewTasks = review, + approvedTasks = approved, doneTasks = done, totalEpics = epics, readyTasks = readyCount', @@ -840,6 +846,7 @@ showTaskStats maybeEpicId = do putText <| " Open: " <> T.pack (show (openTasks stats)) putText <| " In Progress: " <> T.pack (show (inProgressTasks stats)) putText <| " Review: " <> T.pack (show (reviewTasks stats)) + putText <| " Approved: " <> T.pack (show (approvedTasks stats)) putText <| " Done: " <> T.pack (show (doneTasks stats)) putText "" putText <| "Epics: " <> T.pack (show (totalEpics stats)) |
